<?php
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
 * Core SSE support for WooCommerce Appointments.
 *
 * Captures appointment/availability changes and appends them to a bounded
 * transient-backed queue. Provides a minimal admin‑AJAX one‑shot SSE stream
 * for internal use. The primary REST SSE stream is implemented in the v2
 * controller under `includes/api/v2`.
 */
class WC_Appointments_SSE {

	/** Queue transient key. */
	public const TRANSIENT_KEY = 'wc_appointments_sse_events';
	/** Incremental ID option name. */
	public const OPTION_NEXT_ID = 'wc_appointments_sse_next_id';
	/** Max events kept in memory to bound storage. */
	public const MAX_EVENTS = 500;
	/** Transient TTL in seconds (15 minutes) for event queue. */
	public const TTL = 900; // 15 minutes
	/** Deduplication window in seconds to prevent duplicate events. */
	public const DEDUPE_WINDOW = 2;
	/** In-memory deduplication cache for current request. */
	private static array $dedupe_cache = [];

	public function __construct() {
		add_action( 'wp_ajax_wca_sse_stream', [ $this, 'handle_stream' ] );
		add_action( 'wp_ajax_nopriv_wca_sse_stream', [ $this, 'handle_stream' ] );
		add_action( 'woocommerce_new_appointment', function( $id ): void {
			$this->push_event( 'appointment.created', 'appointment', (int) $id, $this->build_appointment_payload( (int) $id ) );
		}, 10, 1 );
		add_action( 'woocommerce_update_appointment', function( $id ): void {
			$this->push_event( 'appointment.updated', 'appointment', (int) $id, $this->build_appointment_payload( (int) $id ) );
		}, 10, 1 );
		add_action( 'woocommerce_appointment_status_changed', function( $from, $to, $id ): void {
			$this->push_event( 'appointment.updated', 'appointment', (int) $id, $this->build_appointment_payload( (int) $id ) );
		}, 10, 3 );
		add_action( 'woocommerce_delete_appointment', function( $id ): void {
			$this->push_event( 'appointment.deleted', 'appointment', (int) $id );
		}, 10, 1 );
		add_action( 'woocommerce_trash_appointment', function( $id ): void {
			$this->push_event( 'appointment.trashed', 'appointment', (int) $id );
		}, 10, 1 );
		add_action( 'woocommerce_appointments_rescheduled_appointment', function( $id ): void {
			$this->push_event( 'appointment.rescheduled', 'appointment', (int) $id, $this->build_appointment_payload( (int) $id ) );
		}, 10, 1 );
		add_action( 'woocommerce_appointments_cancelled_appointment', function( $id ): void {
			$this->push_event( 'appointment.cancelled', 'appointment', (int) $id, $this->build_appointment_payload( (int) $id ) );
		}, 10, 1 );
		add_action( 'woocommerce_appointments_availability_created', function( $id ): void {
			$this->push_event( 'availability.created', 'availability', (int) $id );
		}, 10, 1 );
		add_action( 'woocommerce_appointments_availability_updated', function( $id ): void {
			$this->push_event( 'availability.updated', 'availability', (int) $id );
		}, 10, 1 );
		add_action( 'woocommerce_after_appointments_availability_object_delete', function( $id ): void {
			$this->push_event( 'availability.deleted', 'availability', (int) $id );
		}, 10, 1 );
		add_action( 'trashed_post', [ $this, 'on_trashed_post' ] );
		add_action( 'deleted_post', [ $this, 'on_deleted_post' ] );
		add_action( 'transition_post_status', [ $this, 'on_transition_post_status' ], 10, 3 );
	}


	/**
	 * Build a compact appointment payload for SSE consumers.
	 *
	 * Constructs a lightweight array of appointment data for SSE consumption.
	 */
	protected function build_appointment_payload( $id ): array {
		try {
			$appointment = get_wc_appointment( (int) $id );
			if ( ! $appointment || ! $appointment->get_id() ) {
				return [];
			}
			$staff_ids = (array) $appointment->get_staff_ids();
			$staff_id_single = [] === $staff_ids ? 0 : (int) reset( $staff_ids );

			// Staff details.
			$staff_name   = '';
			$staff_avatar = '';
			if ( $staff_id_single ) {
				$staff_user = get_user_by( 'id', $staff_id_single );
				if ( $staff_user ) {
					$staff_name   = $staff_user->display_name;
					$staff_avatar = get_avatar_url( $staff_user->user_email, [ 'size' => 48 ] );
				}
			}

			// Product details.
			$product_id    = (int) $appointment->get_product_id();
			$product_title = '';
			$cal_color     = '';
			if ( $product_id ) {
				$product_title = get_the_title( $product_id );
				$cal_color     = get_post_meta( $product_id, '_wc_appointment_cal_color', true );
			}
			if ( empty( $cal_color ) ) {
				$cal_color = 'var(--wp-admin-theme-color, #0073aa)';
			}

			// Order details (fetch first to use for customer fallback).
			$order_id   = (int) $appointment->get_order_id();
			$order_info = null;
			if ( $order_id && class_exists( 'WC_Appointment_Data_Store' ) && method_exists( 'WC_Appointment_Data_Store', 'get_calendar_order_details' ) ) {
				$orders_data = WC_Appointment_Data_Store::get_calendar_order_details( [ $order_id ] );
				if ( isset( $orders_data[ $order_id ] ) ) {
					$order_info = $orders_data[ $order_id ];
				}
			}

			// Customer details.
			$customer_id           = (int) $appointment->get_customer_id();
			$customer_name         = __( 'Guest', 'woocommerce-appointments' );
			$customer_first_name   = '';
			$customer_last_name    = '';
			$customer_full_name    = __( 'Guest', 'woocommerce-appointments' );
			$customer_avatar       = '';
			$customer_email        = '';
			$customer_phone        = '';

			// Try to get customer info from user first.
			if ( $customer_id ) {
				$customer_user = get_user_by( 'id', $customer_id );
				if ( $customer_user ) {
					$customer_first_name   = get_user_meta( $customer_id, 'first_name', true );
					$customer_last_name    = get_user_meta( $customer_id, 'last_name', true );
					$customer_display_name = $customer_user->display_name;
					$customer_email        = $customer_user->user_email;
					$customer_phone        = $customer_user->user_phone;
					
					$full_name = trim( $customer_first_name . ' ' . $customer_last_name );
					$customer_name      = '' !== $full_name ? $full_name : $customer_display_name;
					$customer_full_name = $full_name ?: $customer_display_name;
				}
			}

			// Fallback to order billing info if user data is missing.
			if ( ( empty( $customer_first_name ) && empty( $customer_last_name ) ) && $order_info && isset( $order_info['billing'] ) ) {
				$billing = $order_info['billing'];
				$customer_first_name = $billing['first_name'] ?? '';
				$customer_last_name  = $billing['last_name'] ?? '';
				$full_name           = trim( $customer_first_name . ' ' . $customer_last_name );
				
				if ( $full_name ) {
					$customer_full_name = $full_name;
					$customer_name      = $full_name;
					
					// Add (Guest) suffix if no customer ID.
					if ( ! $customer_id ) {
						/* translators: %s: Guest name */
						$customer_full_name = sprintf( _x( '%s (Guest)', 'Guest string with name from appointment order in brackets', 'woocommerce-appointments' ), $full_name );
						$customer_name      = $customer_full_name;
					}
				}
				
				if ( empty( $customer_email ) ) {
					$customer_email = $billing['email'] ?? '';
				}
				if ( empty( $customer_phone ) ) {
					$customer_phone = $billing['phone'] ?? '';
				}
			}
			
			if ( $customer_email ) {
				$customer_avatar = get_avatar_url( $customer_email, [ 'size' => 48 ] );
			}

			return [
				'id'                  => (int) $appointment->get_id(),
				'start'               => (int) $appointment->get_start(),
				'end'                 => (int) $appointment->get_end(),
				'product_id'          => $product_id,
				'product_title'       => $product_title,
				'staff_ids'           => array_map( 'intval', $staff_ids ),
				'staff_id'            => $staff_id_single,
				'staff_name'          => $staff_name,
				'staff_avatar'        => $staff_avatar,
				'status'              => (string) $appointment->get_status(),
				'customer_id'         => $customer_id,
				'customer_name'       => $customer_name,
				'customer_first_name' => $customer_first_name,
				'customer_last_name'  => $customer_last_name,
				'customer_full_name'  => $customer_full_name,
				'customer_avatar'     => $customer_avatar,
				'customer_email'      => $customer_email,
				'customer_phone'      => $customer_phone,
				'order_id'            => $order_id,
				'order_item_id'       => (int) $appointment->get_order_item_id(),
				'order_info'          => $order_info,
				'cost'                => (float) $appointment->get_cost(),
				'all_day'             => (bool) $appointment->get_all_day(),
				'qty'                 => (int) $appointment->get_qty(),
				'customer_status'     => (string) $appointment->get_customer_status(),
				'cal_color'           => (string) $cal_color,
			];
		} catch ( \Exception $e ) {
			return [];
		}
	}

	/**
	 * Read bounded event queue from transient.
	 *
	 * Retrieves the list of stored SSE events from the transient.
	 */
	protected function get_events(): array {
		$events = get_transient( self::TRANSIENT_KEY );
		return is_array( $events ) ? $events : [];
	}

	/**
	 * Persist queue to transient with TTL.
	 *
	 * Saves the event list to the transient with an expiration time.
	 */
	protected function save_events( $events ) {
		set_transient( self::TRANSIENT_KEY, $events, self::TTL );
	}

	/**
	 * Generate incremental event ID.
	 *
	 * Generates the next unique ID for an SSE event.
	 */
	protected function next_id(): int {
		$next = (int) get_option( self::OPTION_NEXT_ID, 1 );
		update_option( self::OPTION_NEXT_ID, $next + 1, false );
		return $next;
	}

	/**
	 * Append a new event to the queue and enforce MAX_EVENTS.
	 *
	 * Adds a new event to the queue, maintaining the size limit.
	 * Includes deduplication to prevent multiple events for the same resource.
	 *
	 * IMPORTANT: For appointments, we track status in the dedupe cache to allow
	 * status change events through even within the same request. This is critical
	 * for shortcode checkout where order_item_meta save and status change save
	 * happen in the same PHP request.
	 */
	protected function push_event( $topic, $resource, $resource_id, $payload = [] ) {
		// Deduplicate: skip if same resource had an event recently.
		// For appointments, we want to consolidate create/update/status_changed into one event,
		// BUT we must allow through events where the status has actually changed.
		$dedupe_key = $resource . ':' . $resource_id;
		$now = time();
		
		// Extract status from payload for status-aware deduplication.
		$current_status = isset( $payload['status'] ) ? (string) $payload['status'] : '';
		
		// Check in-memory cache first (same PHP request).
		if ( isset( self::$dedupe_cache[ $dedupe_key ] ) ) {
			$last = self::$dedupe_cache[ $dedupe_key ];
			
			// If status changed, always allow the event through.
			// This is critical for shortcode checkout where order linking and status
			// change happen in the same request.
			$last_status = $last['status'] ?? '';
			if ( '' !== $current_status && '' !== $last_status && $current_status !== $last_status ) {
				// Status changed - allow through and update cache.
				self::$dedupe_cache[ $dedupe_key ] = [ 'topic' => $topic, 'ts' => $now, 'status' => $current_status ];
				// Continue to push event (don't return).
			} else {
				// If we already pushed an event for this resource in this request,
				// only allow through if it's a more specific topic (e.g., cancelled > updated).
				$dominated_by = [
					'appointment.updated' => [ 'appointment.created', 'appointment.cancelled', 'appointment.rescheduled' ],
					'appointment.created' => [ 'appointment.cancelled', 'appointment.rescheduled' ],
				];
				if ( isset( $dominated_by[ $topic ] ) && in_array( $last['topic'], $dominated_by[ $topic ], true ) ) {
					return; // Skip: a more specific event already queued.
				}
				if ( $last['topic'] === $topic ) {
					return; // Skip: exact same event already queued.
				}
			}
		}
		
		// Check recent events in queue for cross-request deduplication.
		$events = $this->get_events();
		foreach ( array_reverse( $events ) as $evt ) {
			if ( ( $now - $evt['ts'] ) > self::DEDUPE_WINDOW ) {
				break; // Events older than window, stop checking.
			}
			if ( $evt['resource'] === $resource && (int) $evt['resource_id'] === (int) $resource_id ) {
				// Check if status changed from the queued event.
				$evt_status = isset( $evt['payload']['status'] ) ? (string) $evt['payload']['status'] : '';
				if ( '' !== $current_status && '' !== $evt_status && $current_status !== $evt_status ) {
					// Status changed from queued event - allow through.
					break;
				}
				
				// Same resource within window - skip unless this is a more specific topic.
				if ( $evt['topic'] === $topic ) {
					return; // Exact duplicate.
				}
				// If existing event is more specific, skip this one.
				$dominated_by = [
					'appointment.updated' => [ 'appointment.created', 'appointment.cancelled', 'appointment.rescheduled' ],
					'appointment.created' => [ 'appointment.cancelled', 'appointment.rescheduled' ],
				];
				if ( isset( $dominated_by[ $topic ] ) && in_array( $evt['topic'], $dominated_by[ $topic ], true ) ) {
					return; // Skip: a more specific event already exists.
				}
			}
		}
		
		// Record in dedupe cache with status for status-aware deduplication.
		self::$dedupe_cache[ $dedupe_key ] = [ 'topic' => $topic, 'ts' => $now, 'status' => $current_status ];
		
		$events[] = [
			'id' => $this->next_id(),
			'topic' => (string) $topic,
			'resource' => (string) $resource,
			'resource_id' => (int) $resource_id,
			'payload' => is_array( $payload ) ? $payload : [],
			'ts' => $now,
		];
		$size = count( $events );
		if ( self::MAX_EVENTS < $size ) {
			$events = array_slice( $events, $size - self::MAX_EVENTS );
		}
		$this->save_events( $events );
	}

	/**
	 * Check if post is an appointment.
	 *
	 * Checks if the given post ID corresponds to an appointment.
	 *
	 * @param int $post_id Post ID.
	 * @return bool True if post is an appointment.
	 */
	protected function is_appointment_post( $post_id ) {
		$post_id = (int) $post_id;
		if (0 >= $post_id) {
            return false;
        }
		$post = get_post( $post_id );
		if (! $post) {
            return false;
        }
		return ( 'wc_appointment' === $post->post_type );
	}

	/**
	 * Handle trashed post.
	 *
	 * Handles the post trashed action.
	 *
	 * @param int $post_id Post ID.
	 */
	public function on_trashed_post( $post_id ): void {
		if ( $this->is_appointment_post( $post_id ) ) {
			$this->push_event( 'appointment.trashed', 'appointment', (int) $post_id );
		}
	}

	/**
	 * Handle deleted post.
	 *
	 * Handles the post deleted action.
	 *
	 * @param int $post_id Post ID.
	 */
	public function on_deleted_post( $post_id ): void {
		if ( $this->is_appointment_post( $post_id ) ) {
			$this->push_event( 'appointment.deleted', 'appointment', (int) $post_id );
		}
	}

	/**
	 * Handle post status transition.
	 *
	 * Handles post status transitions.
	 *
	 * @param string  $new_status New status.
	 * @param string  $old_status Old status.
	 * @param WP_Post $post       Post object.
	 */
	public function on_transition_post_status( $new_status, $old_status, $post ): void {
		if ( $post && isset( $post->post_type ) && 'wc_appointment' === $post->post_type && ('trash' === $new_status && 'trash' !== $old_status) ) {
			$this->push_event( 'appointment.trashed', 'appointment', (int) $post->ID );
		}
	}

	/**
	 * Internal admin‑AJAX SSE stream.
	 *
	 * Outputs the SSE stream via admin-ajax.php.
	 * One‑shot: Sends up to 50 events and exits.
	 */
	public function handle_stream(): void {
		if ( ! is_user_logged_in() || ! current_user_can( 'manage_woocommerce' ) ) {
			status_header( 403 );
			echo "retry: 10000\n\n";
			exit;
		}
		nocache_headers();
		header( 'Content-Type: text/event-stream' );
		header( 'Cache-Control: no-cache' );
		header( 'Connection: keep-alive' );
		header( 'X-Accel-Buffering: no' );
		@ini_set( 'zlib.output_compression', 0 );
		@ini_set( 'output_buffering', 0 );

		$since = 0;
		if ( isset( $_GET['since_id'] ) ) {
			$since = (int) $_GET['since_id'];
		} elseif ( isset( $_SERVER['HTTP_LAST_EVENT_ID'] ) ) {
			$since = (int) $_SERVER['HTTP_LAST_EVENT_ID'];
		}

		echo "retry: 6000\n";
		$events = $this->get_events();
		if ( [] === $events ) {
			echo "\n";
			@flush();
			exit;
		}

		$max = 50;
		$sent = 0;
		foreach ( $events as $ev ) {
			if ($ev['id'] <= $since) {
                continue;
            }
			$payload = [
				'topic' => $ev['topic'],
				'resource' => $ev['resource'],
				'resource_id' => $ev['resource_id'],
				'payload' => $ev['payload'],
				'ts' => $ev['ts'],
			];
			echo 'id: ' . (int) $ev['id'] . "\n";
			echo 'event: ' . $ev['topic'] . "\n";
			echo 'data: ' . wp_json_encode( $payload ) . "\n\n";
			$sent++;
			if ($sent >= $max) {
                break;
            }
		}
		if ( function_exists( 'fastcgi_finish_request' ) ) {
			@fastcgi_finish_request();
		} else {
			@flush();
		}
		exit;
	}
}

add_action( 'plugins_loaded', function(): void {
    if ( class_exists( 'WooCommerce' ) ) {
        new WC_Appointments_SSE();
    }
}, 20 );
