<?php
/**
 * Read-only REST API controller for table reservation operations.
 *
 * @package FoodOrd
 */

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

/**
 * Class Foodord_Table_Reservations_Rest_Controller
 */
class Foodord_Table_Reservations_Rest_Controller {

	/**
	 * REST API namespace.
	 */
	private const REST_NAMESPACE = 'foodord/v1';

	/**
	 * Reservations service.
	 *
	 * @var Foodord_Table_Reservations
	 */
	private $reservations;

	/**
	 * Shared reservation operations.
	 *
	 * @var Foodord_Table_Reservation_Operations
	 */
	private $operations;

	/**
	 * REST permission callback.
	 *
	 * @var callable
	 */
	private $permission_callback;

	/**
	 * Constructor.
	 *
	 * @param callable $permission_callback Authenticated REST permission callback.
	 */
	public function __construct( callable $permission_callback ) {
		$this->reservations        = new Foodord_Table_Reservations();
		$this->operations          = new Foodord_Table_Reservation_Operations( $this->reservations );
		$this->permission_callback = $permission_callback;
	}

	/**
	 * Register WordPress hooks.
	 */
	public function init(): void {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	/**
	 * Register read-only reservation routes.
	 */
	public function register_routes(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_reservations' ),
					'permission_callback' => $this->permission_callback,
					'args'                => $this->get_collection_args(),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_reservation' ),
					'permission_callback' => $this->permission_callback,
					'args'                => $this->get_create_args(),
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/manual-times',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_manual_reservation_times' ),
				'permission_callback' => $this->permission_callback,
				'args'                => $this->get_manual_times_args(),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/manual-tables',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_manual_reservation_tables' ),
				'permission_callback' => $this->permission_callback,
				'args'                => $this->get_manual_tables_args(),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/staff-holds',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'create_staff_hold' ),
				'permission_callback' => $this->permission_callback,
				'args'                => $this->get_staff_hold_args(),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/staff-holds/(?P<token>[A-Za-z0-9]{64})',
			array(
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'refresh_staff_hold' ),
					'permission_callback' => $this->permission_callback,
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'release_staff_hold' ),
					'permission_callback' => $this->permission_callback,
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/floor-plans',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_reservation_floor_plans' ),
				'permission_callback' => $this->permission_callback,
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/day',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_reservations_day' ),
				'permission_callback' => $this->permission_callback,
				'args'                => $this->get_day_args(),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/snapshot',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_reservations_snapshot' ),
				'permission_callback' => $this->permission_callback,
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/calendar',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_reservations_calendar' ),
				'permission_callback' => $this->permission_callback,
				'args'                => $this->get_calendar_args(),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/(?P<id>\d+)',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_reservation' ),
				'permission_callback' => $this->permission_callback,
				'args'                => array(
					'id' => array(
						'required'          => true,
						'sanitize_callback' => 'absint',
						'validate_callback' => static function ( $value ): bool {
							return is_numeric( $value ) && absint( $value ) > 0;
						},
					),
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/(?P<id>\d+)/status',
			array(
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => array( $this, 'update_reservation_status' ),
				'permission_callback' => $this->permission_callback,
				'args'                => array_merge(
					$this->get_reservation_id_arg(),
					array(
						'status'              => array(
							'required'          => true,
							'sanitize_callback' => 'sanitize_key',
							'validate_callback' => static function ( $value ): bool {
								return in_array( sanitize_key( (string) $value ), self::get_operational_statuses(), true );
							},
						),
						'cancellation_reason' => array(
							'default'           => '',
							'sanitize_callback' => array( 'Foodord_Table_Reservations', 'sanitize_cancellation_reason' ),
						),
					)
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/(?P<id>\d+)/duration',
			array(
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => array( $this, 'update_reservation_duration' ),
				'permission_callback' => $this->permission_callback,
				'args'                => array_merge(
					$this->get_reservation_id_arg(),
					array(
						'duration_minutes' => array(
							'required'          => true,
							'sanitize_callback' => 'absint',
							'validate_callback' => static function ( $value ): bool {
								return in_array( absint( $value ), Foodord_Table_Reservations::get_reservation_duration_options(), true );
							},
						),
					)
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/(?P<id>\d+)/party-size',
			array(
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => array( $this, 'update_reservation_party_size' ),
				'permission_callback' => $this->permission_callback,
				'args'                => array_merge(
					$this->get_reservation_id_arg(),
					array(
						'party_size' => $this->get_party_size_arg(),
					)
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/reservations/(?P<id>\d+)/email',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'resend_reservation_email' ),
				'permission_callback' => $this->permission_callback,
				'args'                => $this->get_reservation_id_arg(),
			)
		);
	}

	/**
	 * Return a filtered reservation collection.
	 *
	 * @param WP_REST_Request $request REST request.
	 */
	public function get_reservations( WP_REST_Request $request ): WP_REST_Response {
		$date_filter = $this->resolve_date_filter( $request );
		$query       = array(
			'page'      => absint( $request->get_param( 'page' ) ),
			'per_page'  => absint( $request->get_param( 'per_page' ) ),
			'status'    => sanitize_key( (string) $request->get_param( 'status' ) ),
			'date'      => Foodord_Table_Reservations::normalize_date_input( (string) $request->get_param( 'date' ) ),
			'date_from' => $date_filter['from'],
			'date_to'   => $date_filter['to'],
			'floor_id'  => absint( $request->get_param( 'floor_id' ) ),
			'search'    => sanitize_text_field( (string) $request->get_param( 'search' ) ),
		);
		$result      = $this->reservations->get_reservations( $query );
		$floors      = $this->prepare_floors();

		return rest_ensure_response(
			array(
				'items'      => array_map( array( $this, 'prepare_reservation' ), $result['items'] ),
				'pagination' => array(
					'page'        => (int) $result['page'],
					'per_page'    => (int) $result['per_page'],
					'total'       => (int) $result['total'],
					'total_pages' => (int) $result['total_pages'],
				),
				'statuses'   => Foodord_Table_Reservations::get_statuses(),
				'floors'     => $floors,
			)
		);
	}

	/**
	 * Return floor plans configured for staff table selection.
	 */
	public function get_reservation_floor_plans(): WP_REST_Response {
		$floor_plan_enabled = Foodord_Table_Reservations::is_table_selection_enabled();
		$floors             = $floor_plan_enabled
			? array_map(
				array( $this, 'prepare_floor_plan' ),
				$this->reservations->get_floor_plan_layouts()
			)
			: array();

		return rest_ensure_response(
			array(
				'available'                  => ! empty( $floors ),
				'customer_selection_enabled' => $floor_plan_enabled,
				'revision'                   => Foodord_Table_Reservations::get_floor_plan_revision(),
				'floors'                     => $floors,
			)
		);
	}

	/**
	 * Return the complete operational timeline for one day.
	 *
	 * @param WP_REST_Request $request REST request.
	 */
	public function get_reservations_day( WP_REST_Request $request ): WP_REST_Response {
		$date               = Foodord_Table_Reservations::normalize_date_input( (string) $request->get_param( 'date' ) );
		$floor_id           = absint( $request->get_param( 'floor_id' ) );
		$operational_day    = rest_sanitize_boolean( $request->get_param( 'operational_day' ) );
		$items              = $this->reservations->get_reservations_for_timeline_day(
			array(
				'date'            => $date,
				'status'          => sanitize_key( (string) $request->get_param( 'status' ) ),
				'floor_id'        => $floor_id,
				'search'          => sanitize_text_field( (string) $request->get_param( 'search' ) ),
				'operational_day' => $operational_day,
			)
		);
		$reserved_table_ids = array_fill_keys(
			array_filter(
				array_map(
					static function ( $reservation ): int {
						return absint( $reservation->table_id ?? 0 );
					},
					$items
				)
			),
			true
		);
		$tables             = array_values(
			array_filter(
				$this->reservations->get_tables( true ),
				static function ( $table ) use ( $floor_id, $reserved_table_ids ): bool {
					$table_floor_id = absint( $table->floor_id ?? 0 );
					if ( $floor_id > 0 && $table_floor_id !== $floor_id ) {
						return false;
					}

					return ! empty( $table->is_active ) || isset( $reserved_table_ids[ absint( $table->id ?? 0 ) ] );
				}
			)
		);

		return rest_ensure_response(
			array(
				'date'     => $date,
				'now'      => current_datetime()->format( DATE_RFC3339 ),
				'schedule' => $this->reservations->get_timeline_day_schedule( $date, $operational_day ),
				'tables'   => array_map( array( $this, 'prepare_timeline_table' ), $tables ),
				'items'    => array_map( array( $this, 'prepare_reservation' ), $items ),
				'floors'   => $this->prepare_floors(),
			)
		);
	}

	/**
	 * Return a lightweight snapshot for desktop background synchronization.
	 */
	public function get_reservations_snapshot(): WP_REST_Response {
		$result = $this->reservations->get_reservations(
			array(
				'page'     => 1,
				'per_page' => 50,
			)
		);

		$items = array_map(
			function ( $reservation ): array {
				return array(
					'id'             => absint( $reservation->id ?? 0 ),
					'status'         => Foodord_Table_Reservations::sanitize_status( (string) ( $reservation->status ?? '' ) ),
					'payment_status' => sanitize_key( (string) ( $reservation->payment_status ?? '' ) ),
					'created_at_gmt' => $this->format_gmt_datetime( (string) ( $reservation->created_at_gmt ?? '' ) ),
					'updated_at_gmt' => $this->format_gmt_datetime( (string) ( $reservation->updated_at_gmt ?? '' ) ),
				);
			},
			$result['items']
		);

		return rest_ensure_response(
			array(
				'items'               => $items,
				'total'               => (int) $result['total'],
				'revision'            => Foodord_Table_Reservations::get_sync_revision(),
				'floor_plan_revision' => Foodord_Table_Reservations::get_floor_plan_revision(),
			)
		);
	}

	/**
	 * Return reservations for one calendar month.
	 *
	 * @param WP_REST_Request $request REST request.
	 */
	public function get_reservations_calendar( WP_REST_Request $request ): WP_REST_Response {
		$month = (string) $request->get_param( 'month' );
		$start = $month . '-01';
		$date  = DateTimeImmutable::createFromFormat( '!Y-m-d', $start );
		if ( false === $date ) {
			return rest_ensure_response(
				array(
					'days'           => array(),
					'week_starts_on' => absint( get_option( 'start_of_week', 1 ) ) % 7,
				)
			);
		}

		$summary = $this->reservations->get_reservation_calendar_summary(
			array(
				'date_from' => $start,
				'date_to'   => $date->format( 'Y-m-t' ),
				'status'    => sanitize_key( (string) $request->get_param( 'status' ) ),
				'floor_id'  => absint( $request->get_param( 'floor_id' ) ),
				'search'    => sanitize_text_field( (string) $request->get_param( 'search' ) ),
			)
		);
		$days    = array();
		foreach ( $summary as $row ) {
			$reservation_date = Foodord_Table_Reservations::normalize_date_input( (string) ( $row->reservation_date ?? '' ) );
			$status           = Foodord_Table_Reservations::sanitize_status( (string) ( $row->status ?? '' ) );
			$count            = absint( $row->reservation_count ?? 0 );
			if ( '' === $reservation_date || $count <= 0 ) {
				continue;
			}

			if ( ! isset( $days[ $reservation_date ] ) ) {
				$days[ $reservation_date ] = array(
					'date'     => $reservation_date,
					'total'    => 0,
					'statuses' => array(),
				);
			}
			$days[ $reservation_date ]['total']              += $count;
			$days[ $reservation_date ]['statuses'][ $status ] = $count;
		}

		return rest_ensure_response(
			array(
				'days'           => array_values( $days ),
				'week_starts_on' => absint( get_option( 'start_of_week', 1 ) ) % 7,
			)
		);
	}

	/**
	 * Return one reservation.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_reservation( WP_REST_Request $request ) {
		$reservation = $this->reservations->get_reservation( absint( $request->get_param( 'id' ) ) );
		if ( ! $reservation ) {
			return new WP_Error(
				'foodord_reservation_not_found',
				__( 'Reservation not found.', 'foodord' ),
				array( 'status' => 404 )
			);
		}

		return rest_ensure_response( $this->prepare_reservation( $reservation ) );
	}

	/**
	 * Return available times for a staff-created reservation.
	 *
	 * @param WP_REST_Request $request REST request.
	 */
	public function get_manual_reservation_times( WP_REST_Request $request ): WP_REST_Response {
		$options = $this->operations->get_manual_time_options(
			(string) $request->get_param( 'date' ),
			absint( $request->get_param( 'party_size' ) ),
			true,
			absint( $request->get_param( 'duration_minutes' ) ),
			true
		);

		return rest_ensure_response(
			array(
				'times'                    => $options['times'],
				'slots'                    => $options['slots'],
				'default_duration_minutes' => $options['default_durations'],
			)
		);
	}

	/**
	 * Return selectable tables and safe durations for a staff slot.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_manual_reservation_tables( WP_REST_Request $request ) {
		$options = $this->operations->get_manual_table_options(
			(string) $request->get_param( 'date' ),
			(string) $request->get_param( 'time' ),
			absint( $request->get_param( 'party_size' ) ),
			true,
			true
		);
		if ( is_wp_error( $options ) ) {
			return $options;
		}

		$tables = array_map(
			static function ( array $table ): array {
				$durations = array_values(
					array_filter(
						array_map( 'absint', (array) ( $table['duration_options'] ?? array() ) )
					)
				);

				return array(
					'id'                       => absint( $table['id'] ?? 0 ),
					'name'                     => sanitize_text_field( (string) ( $table['name'] ?? '' ) ),
					'floor_name'               => sanitize_text_field( (string) ( $table['floor_name'] ?? '' ) ),
					'capacity'                 => absint( $table['capacity'] ?? 0 ),
					'default_duration_minutes' => absint( $table['default_duration_minutes'] ?? 0 ),
					'max_duration_minutes'     => absint( $table['max_duration_minutes'] ?? 0 ),
					'duration_options'         => $durations,
				);
			},
			$options
		);

		return rest_ensure_response( array( 'tables' => array_values( $tables ) ) );
	}

	/**
	 * Create a short staff lease for one timeline slot.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function create_staff_hold( WP_REST_Request $request ) {
		$result = $this->operations->create_staff_hold(
			array(
				'date'       => (string) $request->get_param( 'date' ),
				'time'       => (string) $request->get_param( 'time' ),
				'party_size' => absint( $request->get_param( 'party_size' ) ),
				'table_id'   => absint( $request->get_param( 'table_id' ) ),
			),
			true,
			true
		);
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return new WP_REST_Response(
			array(
				'hold_token'           => sanitize_text_field( (string) ( $result['hold_token'] ?? '' ) ),
				'table_id'             => absint( $result['table_id'] ?? 0 ),
				'table_name'           => sanitize_text_field( (string) ( $result['table_name'] ?? '' ) ),
				'floor_name'           => sanitize_text_field( (string) ( $result['floor_name'] ?? '' ) ),
				'duration_minutes'     => absint( $result['duration'] ?? 0 ),
				'max_duration_minutes' => absint( $result['max_duration'] ?? 0 ),
				'expires_at_gmt'       => sanitize_text_field( (string) ( $result['expires_at_gmt'] ?? '' ) ),
				'ttl_seconds'          => absint( $result['ttl_seconds'] ?? 0 ),
			),
			201
		);
	}

	/**
	 * Refresh a staff lease heartbeat.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function refresh_staff_hold( WP_REST_Request $request ) {
		$result = $this->operations->refresh_staff_hold( (string) $request->get_param( 'token' ) );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return rest_ensure_response(
			array(
				'hold_token'     => sanitize_text_field( (string) ( $result->hold_token ?? '' ) ),
				'expires_at_gmt' => sanitize_text_field( (string) ( $result->expires_at_gmt ?? '' ) ),
				'ttl_seconds'    => absint( $result->ttl_seconds ?? 0 ),
			)
		);
	}

	/**
	 * Release a staff lease.
	 *
	 * @param WP_REST_Request $request REST request.
	 */
	public function release_staff_hold( WP_REST_Request $request ): WP_REST_Response {
		$released = $this->operations->release_staff_hold( (string) $request->get_param( 'token' ) );

		return rest_ensure_response( array( 'released' => $released ) );
	}

	/**
	 * Create a reservation for a phone or in-person request.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function create_reservation( WP_REST_Request $request ) {
		$result = $this->operations->create_manual_reservation(
			array(
				'date'              => (string) $request->get_param( 'date' ),
				'time'              => (string) $request->get_param( 'time' ),
				'party_size'        => absint( $request->get_param( 'party_size' ) ),
				'table_id'          => absint( $request->get_param( 'table_id' ) ),
				'duration_minutes'  => absint( $request->get_param( 'duration_minutes' ) ),
				'status'            => (string) $request->get_param( 'status' ),
				'name'              => (string) $request->get_param( 'customer_name' ),
				'email'             => (string) $request->get_param( 'customer_email' ),
				'phone'             => (string) $request->get_param( 'customer_phone' ),
				'note'              => (string) $request->get_param( 'additional_request' ),
				'hold_token'        => (string) $request->get_param( 'hold_token' ),
				'send_notification' => rest_sanitize_boolean( $request->get_param( 'send_notification' ) ),
			),
			true,
			true
		);
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return new WP_REST_Response(
			array(
				'message'                => __( 'Reservation created.', 'foodord' ),
				'reservation'            => $this->prepare_reservation( $result['reservation'] ),
				'notification_requested' => (bool) $result['notification_requested'],
				'notification_sent'      => (bool) $result['notification_sent'],
			),
			201
		);
	}

	/**
	 * Update a reservation status.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_reservation_status( WP_REST_Request $request ) {
		$result = $this->operations->update_status_with_notification_result(
			absint( $request->get_param( 'id' ) ),
			sanitize_key( (string) $request->get_param( 'status' ) ),
			Foodord_Table_Reservations::sanitize_cancellation_reason( (string) $request->get_param( 'cancellation_reason' ) )
		);
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return rest_ensure_response(
			array(
				'message'                => __( 'Reservation updated.', 'foodord' ),
				'reservation'            => $this->prepare_reservation( $result['reservation'] ),
				'notification_requested' => (bool) $result['notification_requested'],
				'notification_sent'      => (bool) $result['notification_sent'],
			)
		);
	}

	/**
	 * Update a confirmed reservation duration.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_reservation_duration( WP_REST_Request $request ) {
		$result = $this->operations->update_duration(
			absint( $request->get_param( 'id' ) ),
			absint( $request->get_param( 'duration_minutes' ) )
		);
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return rest_ensure_response(
			array(
				'message'     => __( 'Reservation duration updated.', 'foodord' ),
				'reservation' => $this->prepare_reservation( $result ),
			)
		);
	}

	/**
	 * Update a reservation guest count.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_reservation_party_size( WP_REST_Request $request ) {
		$result = $this->operations->update_party_size_with_notification_result(
			absint( $request->get_param( 'id' ) ),
			absint( $request->get_param( 'party_size' ) )
		);
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return rest_ensure_response(
			array(
				'message'                => __( 'Reservation updated.', 'foodord' ),
				'reservation'            => $this->prepare_reservation( $result['reservation'] ),
				'notification_requested' => (bool) $result['notification_requested'],
				'notification_sent'      => (bool) $result['notification_sent'],
			)
		);
	}

	/**
	 * Resend the customer email for the current reservation status.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function resend_reservation_email( WP_REST_Request $request ) {
		$result = $this->operations->resend_status_email( absint( $request->get_param( 'id' ) ) );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return rest_ensure_response(
			array(
				'message' => __( 'Reservation email sent.', 'foodord' ),
			)
		);
	}

	/**
	 * Get collection route arguments.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_collection_args(): array {
		return array(
			'page'       => array(
				'default'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => static function ( $value ): bool {
					return is_numeric( $value ) && absint( $value ) >= 1;
				},
			),
			'per_page'   => array(
				'default'           => 10,
				'sanitize_callback' => 'absint',
				'validate_callback' => static function ( $value ): bool {
					return is_numeric( $value ) && absint( $value ) >= 1 && absint( $value ) <= 100;
				},
			),
			'status'     => array(
				'default'           => '',
				'sanitize_callback' => 'sanitize_key',
				'validate_callback' => static function ( $value ): bool {
					$status = sanitize_key( (string) $value );

					return '' === $status || array_key_exists( $status, Foodord_Table_Reservations::get_statuses() );
				},
			),
			'date'       => $this->get_optional_date_arg(),
			'date_range' => array(
				'default'           => '',
				'sanitize_callback' => 'sanitize_key',
				'validate_callback' => static function ( $value ): bool {
					return in_array( sanitize_key( (string) $value ), array( '', 'today', 'tomorrow', 'this_week', 'this_month', 'custom' ), true );
				},
			),
			'date_from'  => $this->get_optional_date_arg(),
			'date_to'    => $this->get_optional_date_arg(),
			'floor_id'   => array(
				'default'           => 0,
				'sanitize_callback' => 'absint',
				'validate_callback' => static function ( $value ): bool {
					return is_numeric( $value ) && absint( $value ) >= 0;
				},
			),
			'search'     => array(
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => static function ( $value ): bool {
					return strlen( (string) $value ) <= 200;
				},
			),
		);
	}

	/**
	 * Resolve a quick or custom date filter using the WordPress timezone.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return array{from:string,to:string}
	 */
	private function resolve_date_filter( WP_REST_Request $request ): array {
		$preset = sanitize_key( (string) $request->get_param( 'date_range' ) );
		$from   = Foodord_Table_Reservations::normalize_date_input( (string) $request->get_param( 'date_from' ) );
		$to     = Foodord_Table_Reservations::normalize_date_input( (string) $request->get_param( 'date_to' ) );

		if ( 'custom' === $preset || '' === $preset ) {
			return array(
				'from' => $from,
				'to'   => $to,
			);
		}

		$today = current_datetime()->setTime( 0, 0 );
		if ( 'today' === $preset ) {
			$date = $today->format( 'Y-m-d' );

			return array(
				'from' => $date,
				'to'   => $date,
			);
		}

		if ( 'tomorrow' === $preset ) {
			$date = $today->modify( '+1 day' )->format( 'Y-m-d' );

			return array(
				'from' => $date,
				'to'   => $date,
			);
		}

		if ( 'this_week' === $preset ) {
			$week_starts_on = absint( get_option( 'start_of_week', 1 ) ) % 7;
			$days_elapsed   = ( (int) $today->format( 'w' ) - $week_starts_on + 7 ) % 7;
			$week_start     = $today->modify( '-' . $days_elapsed . ' days' );

			return array(
				'from' => $week_start->format( 'Y-m-d' ),
				'to'   => $week_start->modify( '+6 days' )->format( 'Y-m-d' ),
			);
		}

		return array(
			'from' => $today->modify( 'first day of this month' )->format( 'Y-m-d' ),
			'to'   => $today->modify( 'last day of this month' )->format( 'Y-m-d' ),
		);
	}

	/**
	 * Get an optional reservation date argument.
	 *
	 * @return array<string,mixed>
	 */
	private function get_optional_date_arg(): array {
		return array(
			'default'           => '',
			'sanitize_callback' => 'sanitize_text_field',
			'validate_callback' => static function ( $value ): bool {
				if ( '' === trim( (string) $value ) ) {
					return true;
				}

				return '' !== Foodord_Table_Reservations::normalize_date_input( (string) $value );
			},
		);
	}

	/**
	 * Get calendar route arguments.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_calendar_args(): array {
		$collection_args = $this->get_collection_args();

		return array(
			'month'    => array(
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => static function ( $value ): bool {
					return 1 === preg_match( '/\A\d{4}-(?:0[1-9]|1[0-2])\z/', (string) $value );
				},
			),
			'status'   => $collection_args['status'],
			'floor_id' => $collection_args['floor_id'],
			'search'   => $collection_args['search'],
		);
	}

	/**
	 * Get day timeline route arguments.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_day_args(): array {
		$collection_args = $this->get_collection_args();

		return array(
			'date'            => $this->get_required_date_arg(),
			'status'          => $collection_args['status'],
			'floor_id'        => $collection_args['floor_id'],
			'search'          => $collection_args['search'],
			'operational_day' => array(
				'default'           => false,
				'sanitize_callback' => 'rest_sanitize_boolean',
			),
		);
	}

	/**
	 * Get manual reservation time route arguments.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_manual_times_args(): array {
		return array(
			'date'             => $this->get_required_date_arg(),
			'party_size'       => $this->get_party_size_arg(),
			'duration_minutes' => $this->get_duration_arg(),
		);
	}

	/**
	 * Get selectable table query arguments.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_manual_tables_args(): array {
		return array(
			'date'       => $this->get_required_date_arg(),
			'time'       => array(
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => static function ( $value ): bool {
					return 1 === preg_match( '/\A(?:[01]\d|2[0-3]):[0-5]\d\z/', (string) $value );
				},
			),
			'party_size' => $this->get_party_size_arg(),
		);
	}

	/**
	 * Get staff lease creation arguments.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_staff_hold_args(): array {
		return array(
			'date'       => $this->get_required_date_arg(),
			'time'       => array(
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => static function ( $value ): bool {
					return 1 === preg_match( '/\A(?:[01]\d|2[0-3]):[0-5]\d\z/', (string) $value );
				},
			),
			'party_size' => $this->get_party_size_arg(),
			'table_id'   => array(
				'required'          => true,
				'sanitize_callback' => 'absint',
				'validate_callback' => static function ( $value ): bool {
					return absint( $value ) > 0;
				},
			),
		);
	}

	/**
	 * Get manual reservation creation arguments.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_create_args(): array {
		return array(
			'date'               => $this->get_required_date_arg(),
			'time'               => array(
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => static function ( $value ): bool {
					return 1 === preg_match( '/\A(?:[01]\d|2[0-3]):[0-5]\d\z/', (string) $value );
				},
			),
			'party_size'         => $this->get_party_size_arg(),
			'table_id'           => array(
				'default'           => 0,
				'sanitize_callback' => 'absint',
				'validate_callback' => static function ( $value ): bool {
					return absint( $value ) >= 0;
				},
			),
			'duration_minutes'   => $this->get_duration_arg(),
			'status'             => array(
				'default'           => 'confirmed',
				'sanitize_callback' => 'sanitize_key',
				'validate_callback' => static function ( $value ): bool {
					return in_array( sanitize_key( (string) $value ), array( 'pending', 'confirmed' ), true );
				},
			),
			'customer_name'      => $this->get_required_text_arg( 200 ),
			'customer_email'     => array(
				'required'          => true,
				'sanitize_callback' => 'sanitize_email',
				'validate_callback' => static function ( $value ): bool {
					return false !== is_email( (string) $value );
				},
			),
			'customer_phone'     => $this->get_required_text_arg( 80 ),
			'additional_request' => array(
				'default'           => '',
				'sanitize_callback' => 'sanitize_textarea_field',
				'validate_callback' => static function ( $value ): bool {
					return strlen( (string) $value ) <= 1000;
				},
			),
			'hold_token'         => array(
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => static function ( $value ): bool {
					return '' === (string) $value || 1 === preg_match( '/\A[A-Za-z0-9]{64}\z/', (string) $value );
				},
			),
			'send_notification'  => array(
				'default'           => false,
				'sanitize_callback' => 'rest_sanitize_boolean',
				'validate_callback' => 'rest_is_boolean',
			),
		);
	}

	/**
	 * Get a required reservation date argument.
	 *
	 * @return array<string,mixed>
	 */
	private function get_required_date_arg(): array {
		return array(
			'required'          => true,
			'sanitize_callback' => 'sanitize_text_field',
			'validate_callback' => static function ( $value ): bool {
				return '' !== Foodord_Table_Reservations::normalize_date_input( (string) $value );
			},
		);
	}

	/**
	 * Get a required guest count argument.
	 *
	 * @return array<string,mixed>
	 */
	private function get_party_size_arg(): array {
		return array(
			'required'          => true,
			'sanitize_callback' => 'absint',
			'validate_callback' => static function ( $value ): bool {
				return is_numeric( $value ) && absint( $value ) >= 1 && absint( $value ) <= 65535;
			},
		);
	}

	/**
	 * Get an optional staff-selected duration argument.
	 *
	 * @return array<string,mixed>
	 */
	private function get_duration_arg(): array {
		return array(
			'default'           => 0,
			'sanitize_callback' => 'absint',
			'validate_callback' => static function ( $value ): bool {
				$duration = absint( $value );

				return 0 === $duration || in_array( $duration, Foodord_Table_Reservations::get_reservation_duration_options(), true );
			},
		);
	}

	/**
	 * Get a required single-line text argument.
	 *
	 * @param int $maximum_length Maximum field length.
	 * @return array<string,mixed>
	 */
	private function get_required_text_arg( int $maximum_length ): array {
		return array(
			'required'          => true,
			'sanitize_callback' => 'sanitize_text_field',
			'validate_callback' => static function ( $value ) use ( $maximum_length ): bool {
				$length = strlen( trim( (string) $value ) );

				return $length >= 1 && $length <= $maximum_length;
			},
		);
	}

	/**
	 * Get the shared reservation ID route argument.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function get_reservation_id_arg(): array {
		return array(
			'id' => array(
				'required'          => true,
				'sanitize_callback' => 'absint',
				'validate_callback' => static function ( $value ): bool {
					return is_numeric( $value ) && absint( $value ) > 0;
				},
			),
		);
	}

	/**
	 * Get statuses that restaurant staff can assign.
	 *
	 * @return string[]
	 */
	private static function get_operational_statuses(): array {
		return array( 'pending', 'confirmed', 'cancelled', 'completed', 'no_show' );
	}

	/**
	 * Prepare one reservation for the desktop application.
	 *
	 * @param object $reservation Reservation database row.
	 * @return array<string,mixed>
	 */
	private function prepare_reservation( $reservation ): array {
		$status           = Foodord_Table_Reservations::sanitize_status( (string) ( $reservation->status ?? '' ) );
		$payment_status   = sanitize_key( (string) ( $reservation->payment_status ?? '' ) );
		$order_id         = absint( $reservation->order_id ?? 0 );
		$order            = $order_id > 0 && function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : false;
		$duration_options = Foodord_Table_Reservations::get_reservation_duration_options();
		$is_preorder      = $order
			&& class_exists( 'Foodord_Table_Reservation_Deposits' )
			&& Foodord_Table_Reservation_Deposits::is_preorder_order( $order );

		return array(
			'id'                  => absint( $reservation->id ?? 0 ),
			'code'                => sanitize_text_field( (string) ( $reservation->reservation_code ?? '' ) ),
			'date'                => sanitize_text_field( (string) ( $reservation->reservation_date ?? '' ) ),
			'time'                => sanitize_text_field( (string) ( $reservation->reservation_time ?? '' ) ),
			'duration_minutes'    => Foodord_Table_Reservations::sanitize_reservation_duration_minutes( $reservation->duration_minutes ?? 90 ),
			'duration_limits'     => array(
				'minimum_minutes' => (int) $duration_options[0],
				'maximum_minutes' => $this->reservations->get_maximum_duration_for_reservation( $reservation ),
			),
			'party_size'          => absint( $reservation->party_size ?? 0 ),
			'status'              => $status,
			'status_label'        => sanitize_text_field( (string) ( Foodord_Table_Reservations::get_statuses()[ $status ] ?? $status ) ),
			'customer'            => array(
				'name'  => sanitize_text_field( $this->decode_stored_text( (string) ( $reservation->customer_name ?? '' ) ) ),
				'email' => sanitize_email( (string) ( $reservation->customer_email ?? '' ) ),
				'phone' => sanitize_text_field( (string) ( $reservation->customer_phone ?? '' ) ),
			),
			'table'               => array(
				'id'         => absint( $reservation->table_id ?? 0 ),
				'name'       => sanitize_text_field( $this->decode_stored_text( (string) ( $reservation->table_name ?? '' ) ) ),
				'floor_name' => sanitize_text_field( $this->decode_stored_text( (string) ( $reservation->floor_name ?? '' ) ) ),
			),
			'additional_request'  => sanitize_textarea_field( $this->decode_stored_text( (string) ( $reservation->customer_note ?? '' ) ) ),
			'cancellation_reason' => Foodord_Table_Reservations::sanitize_cancellation_reason( $this->decode_stored_text( (string) ( $reservation->cancellation_reason ?? '' ) ) ),
			'payment'             => array(
				'status'           => $payment_status,
				'amount'           => (float) ( $reservation->deposit_amount ?? 0 ),
				'currency'         => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '',
				'order_id'         => $order_id,
				'is_preorder'      => (bool) $is_preorder,
				'awaiting_payment' => 'paid_pending' === $status && 'requires_payment' === $payment_status,
			),
			'permissions'         => array(
				'can_update_status'     => ! $this->operations->is_awaiting_payment( $reservation ),
				'can_update_duration'   => 'confirmed' === $status
					&& absint( $reservation->table_id ?? 0 ) > 0
					&& ! $this->operations->is_awaiting_payment( $reservation ),
				'can_update_party_size' => in_array( $status, array( 'pending', 'confirmed' ), true )
					&& ! $this->operations->is_awaiting_payment( $reservation )
					&& ! $this->operations->has_ended( $reservation ),
				'can_resend_email'      => ! $this->operations->is_awaiting_payment( $reservation )
					&& $this->operations->can_send_customer_status_email( $reservation ),
			),
			'created_at_gmt'      => $this->format_gmt_datetime( (string) ( $reservation->created_at_gmt ?? '' ) ),
			'updated_at_gmt'      => $this->format_gmt_datetime( (string) ( $reservation->updated_at_gmt ?? '' ) ),
		);
	}

	/**
	 * Prepare configured floors for the desktop application.
	 *
	 * @return array<int,array{id:int,name:string}>
	 */
	private function prepare_floors(): array {
		return array_map(
			function ( $floor ): array {
				return array(
					'id'   => absint( $floor->id ?? 0 ),
					'name' => sanitize_text_field( $this->decode_stored_text( (string) ( $floor->name ?? '' ) ) ),
				);
			},
			$this->reservations->get_floors()
		);
	}

	/**
	 * Prepare a configured floor plan for the desktop application.
	 *
	 * @param array<string,mixed> $floor Floor plan data.
	 * @return array<string,mixed>
	 */
	private function prepare_floor_plan( array $floor ): array {
		$tables  = is_array( $floor['tables'] ?? null ) ? $floor['tables'] : array();
		$objects = is_array( $floor['objects'] ?? null ) ? $floor['objects'] : array();

		return array(
			'id'            => absint( $floor['id'] ?? 0 ),
			'name'          => sanitize_text_field( $this->decode_stored_text( (string) ( $floor['name'] ?? '' ) ) ),
			'mode'          => 'grid' === ( $floor['mode'] ?? 'image' ) ? 'grid' : 'image',
			'image_id'      => absint( $floor['image_id'] ?? 0 ),
			'image_url'     => esc_url_raw( (string) ( $floor['image_url'] ?? '' ) ),
			'image_width'   => absint( $floor['image_width'] ?? 0 ),
			'image_height'  => absint( $floor['image_height'] ?? 0 ),
			'image_version' => sanitize_text_field( (string) ( $floor['image_version'] ?? '' ) ),
			'aspect_ratio'  => min( 2.4, max( 1.0, (float) ( $floor['aspect_ratio'] ?? 1.6 ) ) ),
			'snap_to_grid'  => ! isset( $floor['snap_to_grid'] ) || ! empty( $floor['snap_to_grid'] ),
			'grid_density'  => 'fine' === sanitize_key( (string) ( $floor['grid_density'] ?? 'coarse' ) ) ? 'fine' : 'coarse',
			'objects'       => array_map(
				function ( array $floor_object ): array {
					$fill   = sanitize_hex_color( (string) ( $floor_object['fill'] ?? '' ) );
					$stroke = sanitize_hex_color( (string) ( $floor_object['stroke'] ?? '' ) );

					return array(
						'id'           => sanitize_key( (string) ( $floor_object['id'] ?? '' ) ),
						'type'         => sanitize_key( (string) ( $floor_object['type'] ?? '' ) ),
						'x'            => min( 100.0, max( 0.0, (float) ( $floor_object['x'] ?? 0 ) ) ),
						'y'            => min( 100.0, max( 0.0, (float) ( $floor_object['y'] ?? 0 ) ) ),
						'width'        => min( 100.0, max( 0.0, (float) ( $floor_object['width'] ?? 0 ) ) ),
						'height'       => min( 100.0, max( 0.0, (float) ( $floor_object['height'] ?? 0 ) ) ),
						'rotation'     => (float) ( $floor_object['rotation'] ?? 0 ),
						'fill'         => is_string( $fill ) && '' !== $fill ? $fill : '#f3f4f6',
						'stroke'       => is_string( $stroke ) && '' !== $stroke ? $stroke : '#6b7280',
						'border_width' => min( 6.0, max( 0.0, (float) ( $floor_object['border_width'] ?? 1 ) ) ),
						'text'         => sanitize_text_field( $this->decode_stored_text( (string) ( $floor_object['text'] ?? '' ) ) ),
						'flip_x'       => ! empty( $floor_object['flip_x'] ),
					);
				},
				$objects
			),
			'tables'        => array_map(
				function ( array $table ): array {
					return array(
						'id'               => absint( $table['id'] ?? 0 ),
						'name'             => sanitize_text_field( $this->decode_stored_text( (string) ( $table['name'] ?? '' ) ) ),
						'capacity'         => absint( $table['capacity'] ?? 0 ),
						'duration_minutes' => Foodord_Table_Reservations::sanitize_reservation_duration_minutes( $table['duration_minutes'] ?? 90 ),
						'x'                => min( 100.0, max( 0.0, (float) ( $table['x'] ?? 0 ) ) ),
						'y'                => min( 100.0, max( 0.0, (float) ( $table['y'] ?? 0 ) ) ),
						'width'            => min( 60.0, max( 4.0, (float) ( $table['width'] ?? 16 ) ) ),
						'height'           => min( 60.0, max( 4.0, (float) ( $table['height'] ?? 12 ) ) ),
						'rotation'         => (float) ( $table['rotation'] ?? 0 ),
						'shape'            => sanitize_key( (string) ( $table['shape'] ?? 'rectangle' ) ),
						'seat_mode'        => 'even' === sanitize_key( (string) ( $table['seat_mode'] ?? '' ) ) ? 'even' : 'sides',
						'seat_top'         => min( 16, absint( $table['seat_top'] ?? 0 ) ),
						'seat_right'       => min( 16, absint( $table['seat_right'] ?? 0 ) ),
						'seat_bottom'      => min( 16, absint( $table['seat_bottom'] ?? 0 ) ),
						'seat_left'        => min( 16, absint( $table['seat_left'] ?? 0 ) ),
					);
				},
				$tables
			),
		);
	}

	/**
	 * Prepare one table column for the desktop timeline.
	 *
	 * @param object $table Reservation table row.
	 * @return array<string,mixed>
	 */
	private function prepare_timeline_table( $table ): array {
		return array(
			'id'               => absint( $table->id ?? 0 ),
			'name'             => sanitize_text_field( $this->decode_stored_text( (string) ( $table->name ?? '' ) ) ),
			'floor_id'         => absint( $table->floor_id ?? 0 ),
			'floor_name'       => sanitize_text_field( $this->decode_stored_text( (string) ( $table->floor_name ?? '' ) ) ),
			'capacity'         => absint( $table->capacity ?? 0 ),
			'duration_minutes' => Foodord_Table_Reservations::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? 90 ),
		);
	}

	/**
	 * Decode stored display text before exposing it through the REST API.
	 *
	 * @param string $text Stored text.
	 * @return string
	 */
	private function decode_stored_text( string $text ): string {
		return html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
	}

	/**
	 * Convert a GMT database datetime to RFC 3339.
	 *
	 * @param string $value GMT database datetime.
	 * @return string
	 */
	private function format_gmt_datetime( string $value ): string {
		if ( '' === $value ) {
			return '';
		}

		$timestamp = strtotime( $value . ' UTC' );

		return false === $timestamp ? '' : gmdate( DATE_RFC3339, $timestamp );
	}
}
