<?php
/**
 * Event Queue.
 *
 * Manages the event queue for real-time chat updates via SSE.
 *
 * @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;
}

/**
 * Event_Queue class.
 */
class Event_Queue implements Maintenance {
	use Singleton;

	/**
	 * Get the table name for events.
	 *
	 * @return string The full table name with WordPress prefix
	 */
	private function get_table() {
		global $wpdb;
		return $wpdb->prefix . 'agenistrator_events';
	}

	/**
	 * Register hooks.
	 * @since     1.0.0
	 */
	public function register_hooks() {
		// Register activation hook for creating the database table
		add_action( 'agenistrator_activate', array( $this, 'activate_plugin' ) );

		// Register cleanup task to run hourly
		add_action( 'agenistrator_hourly_cleanup', array( $this, 'cleanup_old_events' ) );

		// Schedule the hourly cleanup task if not already scheduled
		if ( ! wp_next_scheduled( 'agenistrator_hourly_cleanup' ) ) {
			wp_schedule_event( time(), 'hourly', 'agenistrator_hourly_cleanup' );
		}

		// Suppress alternate cron redirect on SSE endpoint as early as possible on init.
		if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) {
			add_action( 'init', array( $this, 'suppress_alternate_cron_on_sse' ), 0 );
		}

		// Register SSE endpoint using WordPress rewrite rules
		add_action( 'init', array( $this, 'add_sse_rewrite_rule' ) );
		add_filter( 'query_vars', array( $this, 'add_sse_query_vars' ) );
		add_action( 'parse_request', array( $this, 'handle_sse_request' ) );

		// Cascade delete user events when user is deleted
		add_action( 'delete_user', array( $this, 'deleted_user' ) );
	}

	/**
	 * Handle user deletion - cascade delete user's events
	 *
	 * @param int $user_id The user ID being deleted.
	 * @since     1.0.0
	 */
	public function deleted_user( $user_id ) {
		try {
			$this->delete_events_by_user( (int) $user_id );
		} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Silently fail - cascade deletion should not block user deletion
		}
	}

	/**
	 * Suppress alternate WP-Cron redirect on SSE endpoint requests.
	 *
	 * Runs on the init hook with priority 0, before the default wp_cron() callback.
	 * Uses cheap string operations against REQUEST_URI; only runs when ALTERNATE_WP_CRON is enabled.
	 * @since     1.0.0
	 */
	public function suppress_alternate_cron_on_sse() {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only checking for substring presence, not using the value.
		if ( ! isset( $_SERVER['REQUEST_URI'] ) || strpos( $_SERVER['REQUEST_URI'], '/agenistrator/sse' ) === false ) {
			return;
		}

		if ( ! defined( 'DISABLE_WP_CRON' ) ) {
			// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- DISABLE_WP_CRON is a WordPress core constant (not a plugin constant); we're defining it conditionally to prevent alternate cron from interfering with SSE connections.
			define( 'DISABLE_WP_CRON', true );
		}
	}

	/**
	 * Add rewrite rule for SSE endpoint
	 * @since     1.0.0
	 */
	public function add_sse_rewrite_rule() {
		add_rewrite_rule(
			'^agenistrator/sse/?$',
			'index.php?agenistrator_sse=1',
			'top'
		);
	}

	/**
	 * Add query vars for SSE endpoint
	 * @since     1.0.0
	 */
	public function add_sse_query_vars( $vars ) {
		$vars[] = 'agenistrator_sse';
		return $vars;
	}

	/**
	 * Handle SSE requests
	 * @since     1.0.0
	 */
	public function handle_sse_request( $wp ) {
		if ( ! isset( $wp->query_vars['agenistrator_sse'] ) ) {
			return;
		}

		// Delegate to SSE_Connection singleton
		$sse_connection = SSE_Connection::get_instance();
		$sse_connection->handle_request();
		exit;
	}

	/**
	 * Plugin activation.
	 * @since     1.0.0
	 */
	public function activate_plugin() {
		$this->create_database_tables();

		// Add rewrite rule and flush
		$this->add_sse_rewrite_rule();
		flush_rewrite_rules();
	}

	/**
	 * Plugin deactivation.
	 * @since     1.0.0
	 */
	public function deactivate_plugin() {
		// Clear the scheduled cleanup task
		$timestamp = wp_next_scheduled( 'agenistrator_hourly_cleanup' );
		if ( $timestamp ) {
			wp_unschedule_event( $timestamp, 'agenistrator_hourly_cleanup' );
		}

		// Flush rewrite rules to remove our custom endpoint
		flush_rewrite_rules();
	}

	/**
	 * Plugin uninstall.
	 * @since     1.0.0
	 */
	public function uninstall_plugin() {
		// Clear scheduled cleanup event (also done on deactivate, but ensure it's gone on uninstall)
		wp_clear_scheduled_hook( 'agenistrator_hourly_cleanup' );

		// Flush rewrite rules to remove our custom endpoint
		flush_rewrite_rules();

		$this->drop_tables();
	}

	/**
	 * Create database tables.
	 */
	private function create_database_tables() {
		global $wpdb;

		$charset_collate = $wpdb->get_charset_collate();
		$table_name      = $this->get_table();

		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is a hardcoded string literal with a trusted prefix, not user input.
		$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
			id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
			user_id BIGINT UNSIGNED NULL,
			thread_id BIGINT UNSIGNED NULL,
			event_type VARCHAR(32) NOT NULL,
			event_data JSON NOT NULL,
			created_at BIGINT(20) NOT NULL,
			PRIMARY KEY (id),
			KEY user_id (user_id, id),
			KEY thread_id (thread_id, id),
			KEY created_at (created_at)
		) {$charset_collate};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
	}

	/**
	 * Drop database tables.
	 */
	private function drop_tables() {
		global $wpdb;

		$table_name = $this->get_table();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table DDL; table name is a hardcoded string literal with a trusted prefix, not user input; caching not applicable.
		$wpdb->query( "DROP TABLE IF EXISTS {$table_name}" );
	}

	/**
	 * Push an event to the queue.
	 *
	 * @param Event $event The event to push.
	 * @return int|false The ID of the inserted event or false on failure.
	 * @since     1.0.0
	 */
	public function push_event( $event ) {
		global $wpdb;

		$user_id   = $event instanceof User_Event ? $event->get_user_id() : null;
		$thread_id = $event instanceof Thread_Event ? $event->get_thread_id() : null;

		$data = array(
			'user_id'    => $user_id,
			'thread_id'  => $thread_id,
			'event_type' => $event->get_type(),
			'event_data' => wp_json_encode( $event->get_data() ),
			'created_at' => time(),
		);

		// Build format array based on whether values are null
		$format = array(
			null === $user_id ? null : '%d',
			null === $thread_id ? null : '%d',
			'%s',
			'%s',
			'%d',
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table.
		$result = $wpdb->insert( $this->get_table(), $data, $format );

		if ( $result ) {
			return $wpdb->insert_id;
		}

		return false;
	}

	/**
	 * Get events since the last ID.
	 *
	 * Returns both system-wide events (user_id IS NULL) and events for the specified user
	 * (across all their threads). Frontend is responsible for filtering by thread_id.
	 *
	 * @param int $user_id         The user ID.
	 * @param int $last_id         The last event ID received by the client.
	 * @param int $after_timestamp Optional. Only get events after this timestamp. Default 0.
	 * @return Event[] Array of Event objects.
	 * @since     1.0.0
	 */
	public function get_events( $user_id, $last_id = 0, $after_timestamp = 0 ) {
		global $wpdb;

		$table_name = $this->get_table();

		// Get system-wide events (user_id IS NULL) and user-specific events (user_id = $user_id)
		$query = $wpdb->prepare(
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded
			"SELECT * FROM {$table_name}
			WHERE (user_id IS NULL OR user_id = %d)
			AND id > %d
			AND created_at >= %d
			ORDER BY id ASC",
			$user_id,
			$last_id,
			$after_timestamp
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; query built with $wpdb->prepare() above; table name from get_table() is hardcoded; caching not applicable.
		$results = $wpdb->get_results( $query );

		$events = array();
		if ( $results ) {
			foreach ( $results as $row ) {
				// Build data array for Event::from_array() factory
				$data = array(
					'id'         => (int) $row->id,
					'user_id'    => null === $row->user_id ? null : (int) $row->user_id,
					'thread_id'  => null === $row->thread_id ? null : (int) $row->thread_id,
					'event_type' => $row->event_type,
					'data'       => json_decode( $row->event_data, true ),
					'created_at' => (int) $row->created_at,
				);

				// Use Event factory to reconstruct the proper Event subclass
				$event = Event::from_array( $data );
				if ( null !== $event ) {
					$events[] = $event;
				}
			}
		}

		return $events;
	}

	/**
	 * Get the latest event ID visible to a user.
	 *
	 * Returns the maximum ID from system-wide events (user_id IS NULL) or user's events.
	 *
	 * @param int $user_id The user ID.
	 * @return int The latest event ID, or the table's next AUTO_INCREMENT - 1 if no events exist.
	 * @since     1.0.0
	 */
	public function get_latest_event_id( $user_id ) {
		global $wpdb;

		$table_name = $this->get_table();

		// Get latest ID from system-wide events or user's events
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable.
		$latest_id = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded
				"SELECT MAX(id) FROM {$table_name}
				WHERE user_id IS NULL OR user_id = %d",
				$user_id
			)
		);

		if ( $latest_id ) {
			return (int) $latest_id;
		}

		// Table is empty for this user - use AUTO_INCREMENT to get the current sequence position.
		// This handles the case where events were added and then cleaned up.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- information_schema query; caching not applicable.
		$auto_increment = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT AUTO_INCREMENT FROM information_schema.TABLES
				WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s',
				$wpdb->dbname,
				$table_name
			)
		);

		// AUTO_INCREMENT is the next ID, so subtract 1 to get the last used ID
		return $auto_increment ? (int) $auto_increment - 1 : 0;
	}

	/**
	 * Get recent events of a specific type.
	 *
	 * @param string $event_type The event type to filter by.
	 * @param int    $limit      Maximum number of events to return.
	 * @return Event[] Array of Event instances.
	 * @since     1.0.0
	 */
	public function get_events_by_type( $event_type, $limit = 1000 ) {
		global $wpdb;

		$table_name = $this->get_table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded
				"SELECT id, event_type, event_data, created_at FROM {$table_name}
				WHERE event_type = %s
				ORDER BY id DESC
				LIMIT %d",
				$event_type,
				$limit
			)
		);

		if ( ! $rows ) {
			return array();
		}

		$events = array();
		foreach ( $rows as $row ) {
			$data = json_decode( $row->event_data, true );
			if ( is_array( $data ) ) {
				$event = Event::from_array(
					array(
						'id'         => (int) $row->id,
						'event_type' => $row->event_type,
						'data'       => $data,
						'created_at' => (int) $row->created_at,
					)
				);
				if ( null !== $event ) {
					$events[] = $event;
				}
			}
		}

		return $events;
	}

	/**
	 * Check if a thread is currently processing.
	 *
	 * Uses SQL-level JSON filtering to find the most recent terminal state event
	 * (ready or processing).
	 *
	 * @param int $thread_id The thread ID.
	 * @return bool True if thread is currently processing.
	 * @since     1.0.0
	 */
	public function is_thread_processing( $thread_id ) {
		global $wpdb;

		$table_name = $this->get_table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable.
		$action = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded
				"SELECT JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) AS action FROM {$table_name}
				WHERE thread_id = %d AND event_type = 'control'
					AND JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) IN (%s, %s)
				ORDER BY id DESC LIMIT 1",
				$thread_id,
				Events\Control_Event::ACTION_READY,
				Events\Control_Event::ACTION_PROCESSING
			)
		);

		return Events\Control_Event::ACTION_PROCESSING === $action;
	}

	/**
	 * Check if rate limit wait is currently active (unresumed).
	 *
	 * Looks for the most recent rate_limit_wait or rate_limit_resume control event.
	 * Rate limit is active if the most recent one is rate_limit_wait.
	 *
	 * @param int $thread_id The thread ID.
	 * @return bool True if rate limit wait is active (not yet resumed).
	 * @since     1.0.0
	 */
	public function is_rate_limit_active( $thread_id ) {
		return $this->get_rate_limit_remaining_time( $thread_id ) !== null;
	}

	/**
	 * Get remaining rate limit wait time for a thread.
	 *
	 * Uses SQL-level JSON filtering to find the most recent relevant control event
	 * (rate_limit_wait, rate_limit_resume, ready, or processing).
	 *
	 * @param int $thread_id The thread ID.
	 * @return int|null Remaining wait time in seconds (can be 0 if elapsed), or null if not in rate limit wait.
	 * @since     1.0.0
	 */
	public function get_rate_limit_remaining_time( $thread_id ) {
		global $wpdb;

		$table_name = $this->get_table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded
				"SELECT JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) AS action, JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.params.wait_time')) AS wait_time, created_at FROM {$table_name}
				WHERE thread_id = %d AND event_type = 'control'
					AND JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.action')) IN (%s, %s, %s, %s)
				ORDER BY id DESC LIMIT 1",
				$thread_id,
				Events\Control_Event::ACTION_RATE_LIMIT_WAIT,
				Events\Control_Event::ACTION_RATE_LIMIT_RESUME,
				Events\Control_Event::ACTION_READY,
				Events\Control_Event::ACTION_PROCESSING
			),
			\ARRAY_A
		);

		if ( empty( $row ) || Events\Control_Event::ACTION_RATE_LIMIT_WAIT !== $row['action'] ) {
			return null;
		}

		$wait_time  = isset( $row['wait_time'] ) ? (int) $row['wait_time'] : 0;
		$created_at = (int) $row['created_at'];
		$elapsed    = time() - $created_at;
		$remaining  = $wait_time - $elapsed;

		return max( 0, $remaining );
	}

	/**
	 * Clean up old events.
	 * @since     1.0.0
	 */
	public function cleanup_old_events() {
		global $wpdb;

		// Delete events older than 1 hour
		$cutoff_time = time() - 3600; // 1 hour ago
		$table_name  = $this->get_table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; table name from get_table() is hardcoded; caching not applicable.
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table_name} WHERE created_at < %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is hardcoded
				$cutoff_time
			)
		);
	}

	/**
	 * Delete all events for a thread (silent cascade delete)
	 *
	 * @param int $thread_id The thread ID.
	 * @since     1.0.0
	 */
	public function delete_events_by_thread( $thread_id ) {
		global $wpdb;
		$table_name = $this->get_table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; caching not applicable.
		$wpdb->delete(
			$table_name,
			array( 'thread_id' => $thread_id ),
			array( '%d' )
		);
	}

	/**
	 * Delete all events for a user (silent cascade delete)
	 *
	 * @param int $user_id The user ID.
	 * @since     1.0.0
	 */
	public function delete_events_by_user( $user_id ) {
		global $wpdb;
		$table_name = $this->get_table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; caching not applicable.
		$wpdb->delete(
			$table_name,
			array( 'user_id' => $user_id ),
			array( '%d' )
		);
	}
}
