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

/**
 * Appointment ajax callbacks.
 */
class WC_Appointments_Admin_Ajax {

	/**
	 * Constructor
	 */
	public function __construct() {
		// TODO: Switch from `wp_ajax` to `wc_ajax`
		add_action( 'wp_ajax_woocommerce_add_appointable_staff', [ $this, 'add_appointable_staff' ], 10, 0 );
		add_action( 'wp_ajax_woocommerce_remove_appointable_staff', [ $this, 'remove_appointable_staff' ], 10, 0 );
		add_action( 'wp_ajax_woocommerce_add_staff_product', [ $this, 'add_staff_product' ], 10, 0 );
		add_action( 'wp_ajax_woocommerce_manual_sync', [ $this, 'manual_sync' ], 10, 0 );
		add_action( 'wp_ajax_woocommerce_oauth_redirect', [ $this, 'oauth_redirect' ], 10, 0 );
		add_action( 'wp_ajax_woocommerce_json_search_appointable_products', [ $this, 'json_search_appointable_products' ], 10, 0 );
		add_action( 'wp_ajax_wc-appointment-confirm', [ $this, 'mark_appointment_confirmed' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_calculate_costs', [ $this, 'calculate_costs' ], 10, 0 );
		add_action( 'wp_ajax_nopriv_wc_appointments_calculate_costs', [ $this, 'calculate_costs' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_get_slots', [ $this, 'get_time_slots_for_date' ], 10, 0 );
		add_action( 'wp_ajax_nopriv_wc_appointments_get_slots', [ $this, 'get_time_slots_for_date' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_json_search_order', [ $this, 'json_search_order' ], 10, 0 );

		// Indexing AJAX handlers
		add_action( 'wp_ajax_wc_appointments_start_reindex', [ $this, 'start_reindex' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_stop_reindex', [ $this, 'stop_reindex' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_process_reindex_chunk', [ $this, 'process_reindex_chunk' ], 10, 0 );

		// Modal AJAX handlers
		add_action( 'wp_ajax_wc_appointments_get_appointable_products', [ $this, 'get_appointable_products' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_search_appointable_products', [ $this, 'search_appointable_products' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_get_product_details', [ $this, 'get_product_details' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_get_available_times', [ $this, 'get_available_times' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_create_appointment', [ $this, 'create_appointment' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_get_customer_details', [ $this, 'get_customer_details' ], 10, 0 );
		add_action( 'wp_ajax_wc_appointments_check_availability', [ $this, 'check_availability' ], 10, 0 );
		// New: pretty duration labels for To time options
		add_action( 'wp_ajax_wc_appointments_pretty_duration', [ $this, 'pretty_duration' ], 10, 0 );
		// Calendar view preference
		add_action( 'wp_ajax_wc_appointments_save_calendar_view', [ $this, 'save_calendar_view' ], 10, 0 );
	}

	/**
	 * Add staff
	 */
	public function add_appointable_staff(): void {
		check_ajax_referer( 'add-appointable-staff', 'security' );

		$post_id      = intval( $_POST['post_id'] );
		$loop         = intval( $_POST['loop'] );
		$add_staff_id = intval( $_POST['add_staff_id'] );

		$staff = 0 === $add_staff_id ? new WC_Product_Appointment_Staff( 0 ) : new WC_Product_Appointment_Staff( $add_staff_id );

		// Return html
		if ( 0 !== $add_staff_id ) {
			$appointable_product = get_wc_product_appointment( $post_id );
			$staff_ids           = $appointable_product->get_staff_ids();

			if ( in_array( $add_staff_id, $staff_ids ) ) {
				wp_send_json(
				    [
						'error' => __( 'The staff has already been assigned to this product', 'woocommerce-appointments' ),
					],
				);
			}

			$staff_ids[] = $add_staff_id;
			$appointable_product->set_staff_ids( $staff_ids );
			$appointable_product->save();

			// get the post object due to it is used in the included template
			$post = get_post( $post_id );

			ob_start();
			include __DIR__ . '/views/html-appointment-staff-member.php';
			wp_send_json(
			    [
					'html' => ob_get_clean(),
				],
			);
		}

		wp_send_json(
		    [
				'error' => __( 'Unable to add staff', 'woocommerce-appointments' ),
			],
		);
	}

	/**
	 * Remove staff
	 * TO DO: you should revert post meta logic that is set in class-wc-appointments-admin.php on line 559-593 ????
	 */
	public function remove_appointable_staff(): void {
		check_ajax_referer( 'delete-appointable-staff', 'security' );

		$post_id   = absint( $_POST['post_id'] );
		$staff_id  = absint( $_POST['staff_id'] );
		$product   = get_wc_product_appointment( $post_id );
		$staff_ids = $product->get_staff_ids();
		$staff_ids = array_diff( $staff_ids, [ $staff_id ] );
		$product->set_staff_ids( $staff_ids );
		$product->save();
		die();
	}

	/**
	 * Add staff
	 */
	public function add_staff_product(): void {
		check_ajax_referer( 'add-staff-product', 'security' );

		$add_product_id    = intval( $_POST['add_product_id'] );
		$staff_id          = intval( $_POST['staff_id'] );
		$assigned_products = explode( ',', $_POST['assigned_products'] );

		if ( 0 === $add_product_id ) {
			wp_send_json(
			    [
					'error' => __( 'Unable to add product', 'woocommerce-appointments' ),
				],
			);
		}

		// Return html
		if ( 0 !== $add_product_id ) {
			if ( in_array( $add_product_id, $assigned_products ) ) {
				wp_send_json(
				    [
						'error' => __( 'The product has already been assigned to this staff', 'woocommerce-appointments' ),
					],
				);
			}

			// Variables needed for 'views/html-appointment-staff-fields.php'
			$user_id         = $staff_id;
			$user_product_id = $add_product_id;

			ob_start();
			include __DIR__ . '/views/html-appointment-staff-fields.php';
			wp_send_json(
			    [
					'html' => ob_get_clean(),
				],
			);
		}

		wp_send_json(
		    [
				'error' => __( 'Unable to add product', 'woocommerce-appointments' ),
			],
		);
	}

	/**
	 * Manually perform calendar sync.
	 */
	public function manual_sync(): void {
		$nonce = $_POST['security'];

		if ( [] === $_POST || ! wp_verify_nonce( $nonce, 'add-manual-sync' ) ) {
		    wp_send_json(
		        [
					'error' => __( 'Reload the page and try again.', 'woocommerce-appointments' ),
				],
		    );
		}

		check_ajax_referer( 'add-manual-sync', 'security' );

		$user_id = absint( $_POST['staff_id'] ?? '' );

		if ( $user_id ) {

			// Run GCal sync manually.
			$gcal_integration_class = wc_appointments_gcal();
			$gcal_integration_class->set_user_id( $user_id );
			$gcal_integration_class->sync_from_gcal();
			$last_synced_saved     = get_user_meta( $user_id, 'wc_appointments_gcal_availability_last_synced', true );
			$last_synced_timestamp = ( $last_synced_saved[0] ?? false ) ? absint( $last_synced_saved[0] ) : absint( current_time( 'timestamp' ) );
			$last_synced_counter   = ( $last_synced_saved[1] ?? false ) ? absint( $last_synced_saved[1] ) : 0;

			// Update last synced timestamp.
			$last_synced[] = absint( current_time( 'timestamp' ) );
			$last_synced[] = $last_synced_counter;
			update_user_meta( $user_id, 'wc_appointments_gcal_availability_last_synced', $last_synced );

		} else {

			// Run GCal sync manually.
			$gcal_integration_class = wc_appointments_gcal();
			$gcal_integration_class->sync_from_gcal();
			$last_synced_saved     = get_option( 'wc_appointments_gcal_availability_last_synced' );
			$last_synced_timestamp = ( $last_synced_saved[0] ?? false ) ? absint( $last_synced_saved[0] ) : absint( current_time( 'timestamp' ) );
			$last_synced_counter   = ( $last_synced_saved[1] ?? false ) ? absint( $last_synced_saved[1] ) : 0;

			// Update last synced timestamp.
			$last_synced[] = absint( current_time( 'timestamp' ) );
			$last_synced[] = $last_synced_counter;
			update_option( 'wc_appointments_gcal_availability_last_synced', $last_synced );

		}

		// Last sycned event count, date, time.
		/* translators: %1$s: date format, %2$s: time format */
		$ls_message  = sprintf( __( '%1$s, %2$s', 'woocommerce-appointments' ), date_i18n( wc_appointments_date_format(), $last_synced_timestamp ), date_i18n( wc_appointments_time_format(), $last_synced_timestamp ) );
		$ls_message .= ' - ';
		if ( $user_id ) {
			/* translators: %1$s: link to synced rules, %2$s: sync text */
			$ls_message .= sprintf(
			    '<a href="%1$s" onclick="location.reload()">%2$s</a>',
			    esc_url( admin_url( "user-edit.php?user_id=$user_id#staff-details" ) ),
			    __( 'check synced events', 'woocommerce-appointments' ),
			);
		} else {
			/* translators: %1$s: link to synced rules, %2$s: sync text */
			$ls_message .= sprintf(
			    '<a href="%1$s">%2$s</a>',
			    esc_url( admin_url( 'admin.php?page=wc-settings&tab=appointments&view=synced' ) ),
			    __( 'check synced events', 'woocommerce-appointments' ),
			);
		}

		wp_send_json(
		    [
				'result' => 'SUCCESS',
				'html'   => $ls_message,
			],
		);

		die();
	}

	/**
	 * Manually perform calendar sync.
	 */
	public function oauth_redirect(): void {
		$nonce = $_POST['security'];

		if ( [] === $_POST || ! wp_verify_nonce( $nonce, 'add-oauth-redirect' ) ) {
		    wp_send_json(
		        [
					'error' => __( 'Reload the page and try again.', 'woocommerce-appointments' ),
				],
		    );
		}

		check_ajax_referer( 'add-oauth-redirect', 'security' );

		$user_id = absint( $_POST['staff_id'] ?? '' );
		$logout  = (bool) ( $_POST['logout'] ?? false );

		if ( $user_id ) {
			// Run Gcal oauth redirect.
			$gcal_integration_class = wc_appointments_gcal();
			$gcal_integration_class->set_user_id( $user_id );

			$oauth_url = add_query_arg(
			    [
					'scope'           => $gcal_integration_class->api_scope,
					'redirect_uri'    => $gcal_integration_class->get_redirect_uri(),
					'response_type'   => 'code',
					'client_id'       => $gcal_integration_class->get_client_id(),
					'approval_prompt' => 'force',
					'access_type'     => 'offline',
					'state'           => $user_id,
				],
			    $gcal_integration_class->oauth_uri,
			);

			// Logout.
			if ( $logout ) {
				$oauth_url = add_query_arg(
				    [
						'logout' => 'true',
						'state'  => $user_id,
					],
				    $gcal_integration_class->get_redirect_uri(),
				);
			// Run GCal sync for the first time.
			} else {
				$gcal_integration_class->sync_from_gcal();
			}
		} else {
			// Run Gcal oauth redirect.
			$gcal_integration_class = wc_appointments_gcal();

			$oauth_url = add_query_arg(
			    [
					'scope'           => $gcal_integration_class->api_scope,
					'redirect_uri'    => $gcal_integration_class->get_redirect_uri(),
					'response_type'   => 'code',
					'client_id'       => $gcal_integration_class->get_client_id(),
					'approval_prompt' => 'force',
					'access_type'     => 'offline',
				],
			    $gcal_integration_class->oauth_uri,
			);

			// Logout.
			if ( $logout ) {
				$oauth_url = add_query_arg(
				    [
						'logout' => 'true',
					],
				    $gcal_integration_class->get_redirect_uri(),
				);
			// Run GCal sync for the first time.
			} else {
				$gcal_integration_class->sync_from_gcal();
			}
		}

		wp_send_json(
		    [
				'result' => 'SUCCESS',
				'uri'    => $oauth_url,
			],
		);

		die();
	}

	/**
	 * Search for appointable products and return json.
	 *
	 * @see WC_AJAX::json_search_appointable_products()
	 */
	public static function json_search_appointable_products(): void {
		check_ajax_referer( 'search-products', 'security' );

		$limit = empty( $_GET['limit'] ) ? absint( apply_filters( 'woocommerce_json_search_limit', 30 ) ) : absint( $_GET['limit'] );

		if (! empty( $_GET['include'] )) {
            array_map( 'absint', (array) wp_unslash( $_GET['include'] ) );
        }
		if (! empty( $_GET['exclude'] )) {
            array_map( 'absint', (array) wp_unslash( $_GET['exclude'] ) );
        }

		$term       = (string) wc_clean( wp_unslash( $_GET['term'] ?? '' ) );
		$data_store = WC_Data_Store::load( 'product' );
		$ids        = $data_store->search_products( $term, '', true, false, $limit );

		$product_objects = array_filter( array_map( 'wc_get_product', $ids ), 'wc_products_array_filter_readable' );
		$products        = [];
		$current_user_id = get_current_user_id();

		foreach ( $product_objects as $product_object ) {
			if ( ! $product_object->is_type( 'appointment' ) ) {
				continue;
			}
			$staff_ids        = $product_object->get_staff_ids();
			$personal_product = '';
			if ( $product_object->has_staff() && in_array( $current_user_id, (array) $staff_ids ) ) {
				$personal_product = ' - <strong>' . esc_html( 'assigned to you', 'woocommerce-appointments' ) . '</strong>';
			}
			$products[ $product_object->get_id() ] = rawurldecode( $product_object->get_formatted_name() ) . $personal_product;
		}

		wp_send_json( $products );
	}

	/**
	 * Mark an appointment confirmed
	 */
	public function mark_appointment_confirmed(): void {
		if ( ! current_user_can( 'manage_appointments' ) ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'woocommerce-appointments' ) );
		}
		if ( ! check_admin_referer( 'wc-appointment-confirm' ) ) {
			wp_die( esc_html__( 'You have taken too long. Please go back and retry.', 'woocommerce-appointments' ) );
		}
		$appointment_id = isset( $_GET['appointment_id'] ) && (int) $_GET['appointment_id'] ? (int) $_GET['appointment_id'] : '';
		if ( ! $appointment_id ) {
			die;
		}

		$appointment = get_wc_appointment( $appointment_id );
		if ( WC_Appointments_Constants::STATUS_CONFIRMED !== $appointment->get_status() ) {
			$appointment->update_status( WC_Appointments_Constants::STATUS_CONFIRMED );
		}

		wp_safe_redirect( wp_get_referer() );
		die();
	}

	/**
	 * Calculate costs
	 *
	 * Take posted appointment form values and then use these to quote a price for what has been chosen.
	 * Returns a string which is appended to the appointment form.
	 */
	public function calculate_costs(): void {
		// Set a flag to prevent currency conversion in admin
		// This ensures appointment costs are shown in base currency in admin modal
		// Check referer to distinguish admin AJAX from front-end AJAX (is_admin() is true for all AJAX)
		$referer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) );
		$is_admin_request = ( $referer && ( strpos( $referer, '/wp-admin/' ) !== false || strpos( $referer, (string) admin_url() ) !== false ) );

		if ( $is_admin_request && ! defined( 'WC_APPOINTMENTS_ADMIN_CREATE' ) ) {
			define( 'WC_APPOINTMENTS_ADMIN_CREATE', true );
		}

		$posted = [];

		parse_str( $_POST['form'], $posted );

		$product_id = $posted['add-to-cart'] ?? 0;
		if ( ! $product_id && isset( $posted['appointable-product-id'] ) ) {
			$product_id = $posted['appointable-product-id'];
		}
		$product = get_wc_product_appointment( $product_id );

		if ( ! $product ) {
			wp_send_json(
			    [
					'result'  => 'ERROR',
					'success' => false,
					'html'    => apply_filters(
					    'woocommerce_appointments_appointment_cost_html',
					    '<span class="appointment-error">' . __( 'This appointment is unavailable.', 'woocommerce-appointments' ) . '</span>',
					    null,
					    $posted,
					),
					'data'    => [
						'html' => apply_filters(
						    'woocommerce_appointments_appointment_cost_html',
						    '<span class="appointment-error">' . __( 'This appointment is unavailable.', 'woocommerce-appointments' ) . '</span>',
						    null,
						    $posted,
						),
					],
				],
			);
		}

		new WC_Appointment_Form( $product );
		$cost             = WC_Appointments_Cost_Calculation::calculate_appointment_cost( $posted, $product );

		if ( is_wp_error( $cost ) ) {
			wp_send_json(
			    [
					'result'  => 'ERROR',
					'success' => false,
					'html'    => apply_filters(
					    'woocommerce_appointments_appointment_cost_html',
					    '<span class="appointment-error">' . $cost->get_error_message() . '</span>',
					    null,
					    $posted,
					),
					'data'    => [
						'html' => apply_filters(
						    'woocommerce_appointments_appointment_cost_html',
						    '<span class="appointment-error">' . $cost->get_error_message() . '</span>',
						    null,
						    $posted,
						),
					],
				],
			);
		}

		if ( 'incl' === get_option( 'woocommerce_tax_display_shop' ) ) {
			$display_price = wc_get_price_including_tax(
			    $product,
			    [
					'price' => $cost,
				],
			);
		} else {
			$display_price = wc_get_price_excluding_tax(
			    $product,
			    [
					'price' => $cost,
				],
			);
		}

		// In admin, format price using base currency to prevent conversion
		// This ensures the displayed cost matches what will be saved
		if ( is_admin() && current_user_can( 'edit_appointments' ) ) {
			$base_currency = get_woocommerce_currency();
			$cost_html = wc_price( $display_price, [ 'currency' => $base_currency ] ) . $product->get_price_suffix( $cost, 1 );
		} else {
			$cost_html = $product->get_cost_html( $cost );
		}

		$appointment_cost_html = '
			<dl>
				<dt>' . _x( 'Cost', 'appointment cost string', 'woocommerce-appointments' ) . ':</dt>
				<dd><strong>' . $cost_html . '</strong></dd>
			</dl>
		';
		$appointment_cost_html = apply_filters(
		    'woocommerce_appointments_appointment_cost_html',
		    $appointment_cost_html,
		    $product,
		    $posted,
		);

		wp_send_json(
		    [
				'result'     => 'SUCCESS',
				'success'    => true,
				'html'       => apply_filters(
				    'woocommerce_appointments_calculated_appointment_cost_success_output',
				    $appointment_cost_html,
				    $display_price,
				    $product,
				),
				'raw'        => (float) $display_price,
				'raw_price'  => (float) $display_price,
				'data'       => [
					'html'      => apply_filters(
					    'woocommerce_appointments_calculated_appointment_cost_success_output',
					    $appointment_cost_html,
					    $display_price,
					    $product,
					),
					'raw'       => (float) $display_price,
					'raw_price' => (float) $display_price,
				],
			],
		);
	}

	/**
	 * Get a list of time slots available on a date
	 */
	public function get_time_slots_for_date(): bool {
		// Clean posted data.
		$posted = [];
		parse_str( $_POST['form'], $posted );

 		$product_id = $posted['add-to-cart'] ?? 0;
 		if ( ! $product_id && isset( $posted['appointable-product-id'] ) ) {
 			$product_id = $posted['appointable-product-id'];
 		}

 		// Product Checking.
 		$product = get_wc_product_appointment( $product_id );
		if ( ! $product ) {
			return false;
		}

		// Rescheduling appointment?
 		if ( isset( $posted['reschedule-appointment'] ) && $posted['reschedule-appointment'] && isset( $posted['appointment-id'] ) ) {
 			$appointment       = get_wc_appointment( absint( $posted['appointment-id'] ) );
  			$is_wc_appointment = is_a( $appointment, 'WC_Appointment' );

 			// Is appointment object?
 			if ( $is_wc_appointment ) {
 				// Make sure only appointment duration is set to product.
 				$appointment_duration = $appointment->get_duration_parameters();
 				$product->set_duration( $appointment_duration['duration'] );
 				$product->set_duration_unit( $appointment_duration['duration_unit'] );

 				// Make sure qty is taken into account.
 				if ( 1 < $product->get_qty() ) {
 					$remaining_qty = abs( $product->get_qty() ) - abs( $appointment->get_qty() );
 					$product->set_qty( max( $remaining_qty, 0 ) ); #negative number is set to 0.
 				}

 				// Make sure only appointment staff is set to product.
 				if ( $appointment->get_staff_ids() ) {
 					#$product->set_staff_assignment( 'automatic' );
 					$product->set_staff_ids( $appointment->get_staff_ids() );
 					$product->set_staff_nopref( false );
				}
			}
		}

		// Addons duration.
		$addons_duration = $_POST['duration'] ?? 0;

		// Check selected date.
 		if ( isset($posted['wc_appointments_field_start_date_year']) && !in_array($posted['wc_appointments_field_start_date_year'], ['', '0', []], true) && (isset($posted['wc_appointments_field_start_date_month']) && !in_array($posted['wc_appointments_field_start_date_month'], ['', '0', []], true)) && (isset($posted['wc_appointments_field_start_date_day']) && !in_array($posted['wc_appointments_field_start_date_day'], ['', '0', []], true)) ) {
 			$year      = max( date( 'Y' ), absint( $posted['wc_appointments_field_start_date_year'] ) );
 			$month     = absint( $posted['wc_appointments_field_start_date_month'] );
 			$day       = absint( $posted['wc_appointments_field_start_date_day'] );
 			$timestamp = strtotime( "{$year}-{$month}-{$day}" );
 		}
 		if ( 0 === $timestamp || false === $timestamp ) {
			die( esc_html__( 'Please enter a valid date.', 'woocommerce-appointments' ) );
		}

 		// Intervals.
 		[$interval, $base_interval] = $product->get_intervals();

 		// Adjust duration if extended with addons.
 		if ( 0 !== $addons_duration ) {
 			$interval += absint( $addons_duration );
 			$interval  = 0 < $interval ? $interval : 0; #turn negative duration to zero.
 		}

		// TIMEZONE HANDLING FOR AVAILABILITY CHECKS:
		// ==========================================
		// This section handles timezone conversion when checking availability.
		// The key principle: Always compute availability in site timezone, but allow
		// customers to select times in their own timezone (if product supports it).
		//
		// Flow:
		// 1. Determine customer-selected timezone (from form or default to site timezone)
		// 2. Parse customer input in customer timezone
		// 3. Convert to site timezone for availability calculations
		// 4. Return timestamps that are UTC but represent site timezone times
		//
		// See TIMEZONE_ARCHITECTURE.md for detailed explanation.

		// Determine customer-selected timezone (for display and input parsing).
		// Future: This will come from customer account settings when implemented.
		$tz_selected = '';
		if ( is_a( $product, 'WC_Product_Appointment' ) && $product->has_timezones() && (isset($posted['wc_appointments_field_timezone']) && !in_array($posted['wc_appointments_field_timezone'], ['', '0', []], true)) ) {
			$tz_selected = sanitize_text_field( $posted['wc_appointments_field_timezone'] );
		}
		if ( ! $tz_selected ) {
			$tz_selected = function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : wc_timezone_string();
		}
		if ( empty( $tz_selected ) ) {
			$tz_selected = 'UTC';
		}

		// Always compute availability using site timezone.
		// This ensures consistent availability calculations regardless of customer timezone.
		$tz_site_string = function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : wc_timezone_string();
		if ( empty( $tz_site_string ) ) {
			$tz_site_string = 'UTC';
		}
		$tz_site   = new DateTimeZone( $tz_site_string );
		$tz_custom = new DateTimeZone( $tz_selected );

		// Build local day in customer's timezone, then convert to site timezone boundaries.
		// The resulting timestamps ($from, $to) are UTC but represent site timezone times.
		// When extracting components later, use local methods (date(), format()) not UTC methods.
		$dt_day_local = new DateTimeImmutable( sprintf( '%04d-%02d-%02d 00:00:00', $year, $month, $day ), $tz_custom );
		$from         = $dt_day_local->setTimezone( $tz_site )->getTimestamp();
		$to           = $dt_day_local->setTimezone( $tz_site )->modify( '+1 day' )->getTimestamp() + $interval;
		$time_to_check = 0;
		if ( isset($posted['wc_appointments_field_start_date_time']) && !in_array($posted['wc_appointments_field_start_date_time'], ['', '0', []], true) ) {
			try {
				// Parse selected time in customer timezone, convert to site timezone.
				// Result is UTC timestamp representing site timezone time.
				$dt_sel_local  = new DateTimeImmutable( sprintf( '%04d-%02d-%02d %s', $year, $month, $day, $posted['wc_appointments_field_start_date_time'] ), $tz_custom );
				$time_to_check = $dt_sel_local->setTimezone( $tz_site )->getTimestamp();
			} catch ( Exception $e ) {
				$time_to_check = 0;
			}
		}
 		$staff_id_to_check = empty( $posted['wc_appointments_field_staff'] ) ? 0 : $posted['wc_appointments_field_staff'];
 		$staff_member      = $product->get_staff_member( absint( $staff_id_to_check ) );
 		$staff             = $product->get_staff();
 		if ( $staff_id_to_check && $staff_member ) {
 			$staff_id_to_check = $staff_member->get_id();
 		} elseif ( ( $staff ) && count( $staff ) === 1 ) {
 			$staff_id_to_check = current( $staff )->ID;
 		} elseif ( $product->is_staff_assignment_type( 'all' ) ) {
 			$staff_id_to_check = $product->get_staff_ids();
 		} else {
 			$staff_id_to_check = 0;
 		}

		// Timezone for response data: prefer customer-selected timezone when product supports timezones.
		// This is used for display purposes in the response. The actual stored timestamps
		// will be in site timezone (stored as UTC representing site timezone time).
		$tzstring = '';
		if ( is_a( $product, 'WC_Product_Appointment' ) && $product->has_timezones() && (isset($posted['wc_appointments_field_timezone']) && !in_array($posted['wc_appointments_field_timezone'], ['', '0', []], true)) ) {
			$tzstring = sanitize_text_field( $posted['wc_appointments_field_timezone'] );
		}
		if ( ! $tzstring ) {
			$tzstring = function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : wc_timezone_string();
		}
		if ( empty( $tzstring ) ) {
			$tzstring = 'UTC';
		}

 		// Generate slots using a cached controller (fast path).


 		$slots = WC_Appointments_Controller::get_cached_slots_in_range(
		    $product,
		    $from,
		    $to,
		    [ $interval, $base_interval ],
		    $staff_id_to_check,
		);

		// Compute available slots using a cached controller, then render.
		$available = WC_Appointments_Controller::get_cached_time_slots(
		    $product,
		    [
				'slots'     => $slots,
				'intervals' => [ $interval, $base_interval ],
				'staff_id'  => $staff_id_to_check,
				'from'      => $from,
				'to'        => $to,
				'include_sold_out' => false,
			],
		);

		// Render cached time-slots HTML directly from controller (pre-filtered).
		$slot_html = WC_Appointments_Controller::get_cached_time_slots_html(
		    $product,
		    [
				'available'        => $available,
				'intervals'        => [ $interval, $base_interval ],
				'staff_id'         => $staff_id_to_check,
				'time_to_check'    => $time_to_check,
				'from'             => $from,
				'to'               => $to,
				'timestamp'        => $timestamp,
				'timezone'         => $tzstring,
				'include_sold_out' => false,
			],
		);

 		if ( empty( $slot_html ) ) {
			$slot_html .= __( 'No slots available.', 'woocommerce-appointments' );
		}

		if ( defined( 'WCA_TEST_SUITE_NO_DIE' ) && WCA_TEST_SUITE_NO_DIE ) {
			echo $slot_html;
			return true;
		}

		die( $slot_html );
	}

	/**
	 * Search for orders and return json.
	 */
	public function json_search_order(): void {
		global $wpdb;

		check_ajax_referer( 'search-appointment-order', 'security' );

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			wp_die( -1 );
		}

		$term = wc_clean( stripslashes( $_GET['term'] ) );

		if ( empty( $term ) ) {
			die();
		}

		$found_orders = [];

		$term = apply_filters( 'woocommerce_appointment_json_search_order_number', $term );

		$query_orders = $wpdb->get_results(
		    $wpdb->prepare(
		        "SELECT ID, post_title FROM {$wpdb->posts} AS posts
				WHERE posts.post_type = 'shop_order'
				AND posts.ID LIKE %s
				LIMIT 10",
		        $term . '%',
		    ),
		);

		if ( WC_Appointment_Order_Compat::is_cot_enabled() ) {
			$query_orders = $wpdb->get_results(
			    $wpdb->prepare(
			        "SELECT id FROM {$wpdb->prefix}wc_orders AS wc_orders
					WHERE wc_orders.id LIKE %s
					LIMIT 10",
			        $term . '%',
			    ),
			);
			if ( $query_orders ) {
				foreach ( $query_orders as $item ) {
					$order = wc_get_order( $item->id );
					if ( is_a( $order, 'WC_Order' ) ) {
						$found_orders[ $order->get_id() ] = $order->get_order_number() . ' &ndash; ' . date_i18n( wc_appointments_date_format(), strtotime( $order->get_date_created() ) );
					}
				}
			}
		} else {
			$query_orders = $wpdb->get_results(
			    $wpdb->prepare(
			        "SELECT ID, post_title FROM {$wpdb->posts} AS posts
					WHERE posts.post_type = 'shop_order'
					AND posts.ID LIKE %s
					LIMIT 10",
			        $term . '%',
			    ),
			);
			if ( $query_orders ) {
				foreach ( $query_orders as $item ) {
					$order = wc_get_order( $item->ID );
					if ( is_a( $order, 'WC_Order' ) ) {
						$found_orders[ $order->get_id() ] = $order->get_order_number() . ' &ndash; ' . date_i18n( wc_appointments_date_format(), strtotime( $order->get_date_created() ) );
					}
				}
			}
		}

		wp_send_json( $found_orders );
	}

	/**
	 * Start reindex process
	 */
	public function start_reindex(): void {
		check_ajax_referer( 'wc_appointments_admin_nonce', '_wpnonce' );

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'woocommerce-appointments' ) ] );
		}

		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );
		@ignore_user_abort( true );

		// Check if indexed availability is enabled
		if ( 'yes' !== get_option( 'wc_appointments_use_indexed_cache', 'no' ) ) {
			wp_send_json_error( [ 'message' => __( 'Indexed availability is not enabled. Please enable "Use Indexed Availability" option first.', 'woocommerce-appointments' ) ] );
		}

		try {
			// Clear index and caches up-front to ensure fresh data is used.
			// 1) Purge the availability/appointment index table entirely.
			if ( class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
				$data_store = new WC_Appointments_Availability_Cache_Data_Store();
				if ( method_exists( $data_store, 'clear_all' ) ) {
					// Delete all rows from wc_appointments_availability_cache and flush its object cache group.
					$data_store->clear_all();
				}
			}

			// 2) Invalidate front-end/transient caches used by availability and schedule rendering.
			if ( class_exists( 'WC_Appointments_Cache' ) ) {
				// Clear transients and, if using an external object cache, flush it.
				WC_Appointments_Cache::clear_cache();
				// Also bump known cache groups that gate schedule calculations.
				WC_Appointments_Cache::invalidate_cache_group( 'appointments' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_ts' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_dr' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_fo' );
				WC_Appointments_Cache::invalidate_cache_group( 'staff_ps' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_staff_ids' );
			}

			global $wpdb;
			$rule_ids = [];
			if ( isset( $wpdb ) ) {
				$table    = $wpdb->prefix . 'wc_appointments_availability';
				$rule_ids = $wpdb->get_col( "SELECT ID FROM {$table} ORDER BY ID ASC" );
			}
			$rule_ids = array_map( 'intval', (array) $rule_ids );

			// 3) Ensure each availability rule object will be read fresh from DB (avoid stale object cache).
			// Optimize: Use group flush instead of individual deletes for better performance
			if ( [] !== $rule_ids && class_exists( 'WC_Appointments_Availability_Data_Store' ) ) {
				// If the cache backend supports group flush, clear the entire availability group (much faster).
				if ( function_exists( 'wp_cache_flush_group' ) ) {
					wp_cache_flush_group( WC_Appointments_Availability_Data_Store::CACHE_GROUP );
				} else {
					// Fallback: Only delete individual items if group flush is not available
					foreach ( $rule_ids as $rid ) {
						wp_cache_delete( $rid, WC_Appointments_Availability_Data_Store::CACHE_GROUP );
					}
				}
			}

			$appt_ids = [];
			if ( class_exists( 'WC_Appointment_Data_Store' ) ) {
				// During manual re-indexing, include appointments within the retention period (30 days)
				// This matches the retention period used by prune_past_rows()
				$retention_days = defined( 'WC_Appointments_Cache_Availability::INDEX_RETENTION_DAYS' )
					? WC_Appointments_Cache_Availability::INDEX_RETENTION_DAYS
					: 30;
				$retention_cut = time() - ( $retention_days * DAY_IN_SECONDS );

				$appt_ids = WC_Appointment_Data_Store::get_appointment_ids_by( [
					'date_after' => $retention_cut,
					'limit'      => 100000,
					'offset'     => 0,
					'order'      => 'ASC',
					'order_by'   => 'start_date',
				] );
			}
			$appt_ids = array_map( 'intval', (array) $appt_ids );

			// Initialize queue storage for chunked processing
			update_option( 'wc_appointments_index_queue', [
				'rule_ids'   => $rule_ids,
				'appt_ids'   => $appt_ids,
				'rule_index' => 0,
				'appt_index' => 0,
			], false );

			// Signal to the indexer that manual reindex is running and should force full indexing per rule.
			update_option( 'wc_appointments_manual_reindex_full_indexing', 1, false );

			// Initialize progress tracking in running state
			$progress = [
				'status'             => 'running',
				'total_rules'        => count( $rule_ids ),
				'processed_rules'    => 0,
				'total_appointments' => count( $appt_ids ),
				'processed_appts'    => 0,
				'cache_rows_created' => 0, // Track total cache rows (indexes) created
				'started_at'         => time(),
				'updated_at'         => time(),
			];
			update_option( 'wc_appointments_index_progress', $progress, false );

			wp_send_json_success( [
				'message'  => __( 'Reindex started successfully.', 'woocommerce-appointments' ),
				'progress' => $progress,
			] );
		} catch ( Exception $e ) {
			wp_send_json_error( [
				/* translators: %s: error message */
				'message' => sprintf( __( 'Failed to start reindex: %s', 'woocommerce-appointments' ), $e->getMessage() ),
			] );
		}
	}


	/**
	 * Stop reindex process
	 */
	public function stop_reindex(): void {
		check_ajax_referer( 'wc_appointments_admin_nonce', '_wpnonce' );

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'woocommerce-appointments' ) ] );
		}

		try {
			// Get current progress
			$progress = get_option( 'wc_appointments_index_progress', [] );

			// Set status to stopped if it was running
			if ( isset( $progress['status'] ) && in_array( $progress['status'], [ 'running', 'queued' ] ) ) {
				$progress['status'] = 'stopped';
				$progress['updated_at'] = time();
				update_option( 'wc_appointments_index_progress', $progress, false );
				// Clear the manual reindex flag so background passes revert to bounded limits.
				delete_option( 'wc_appointments_manual_reindex_full_indexing' );

				wp_send_json_success( [
					'message' => __( 'Reindex process stopped successfully.', 'woocommerce-appointments' ),
					'progress' => $progress,
				] );
			} else {
				wp_send_json_success( [
					'message' => __( 'No active reindex process to stop.', 'woocommerce-appointments' ),
					'progress' => $progress,
				] );
			}
		} catch ( Exception $e ) {
			wp_send_json_error( [
				/* translators: %s: error message */
				'message' => sprintf( __( 'Failed to stop reindex: %s', 'woocommerce-appointments' ), $e->getMessage() ),
			] );
		}
	}

	/**
	 * Process a manual reindex chunk (rules + appointments) to avoid timeouts.
	 */
	public function process_reindex_chunk(): void {
		check_ajax_referer( 'wc_appointments_admin_nonce', '_wpnonce' );

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'woocommerce-appointments' ) ] );
		}

		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );
		@ignore_user_abort( true );

		// Increase default chunk size for better performance (was 25, now 50)
		// This processes more items per request, reducing total number of AJAX calls
		$limit = max( 1, (int) ( $_POST['limit'] ?? 50 ) );

		$progress = get_option( 'wc_appointments_index_progress', [] );
		$queue = get_option( 'wc_appointments_index_queue', [
			'rule_ids'   => [],
			'appt_ids'   => [],
			'rule_index' => 0,
			'appt_index' => 0,
		] );

		if ( empty( $progress ) || ! isset( $progress['status'] ) ) {
			$progress = [
				'status'             => 'idle',
				'total_rules'        => 0,
				'processed_rules'    => 0,
				'total_appointments' => 0,
				'processed_appts'    => 0,
				'cache_rows_created' => 0,
				'updated_at'         => time(),
			];
		}

		// Initialize cache_rows_created if not set
		if ( ! isset( $progress['cache_rows_created'] ) ) {
			$progress['cache_rows_created'] = 0;
		}

		if ( in_array( $progress['status'], [ 'stopped', 'completed', 'idle' ], true ) ) {
			wp_send_json_success( [ 'progress' => $progress ] );
		}

		$processed_this_call = 0;

		// Process availability rules.
		$rule_ids   = (array) ( $queue['rule_ids'] ?? [] );
		$rule_index = (int) ( $queue['rule_index'] ?? 0 );

		$progress['total_rules'] = (int) ( $progress['total_rules'] ?? count( $rule_ids ) );
		if ( 0 === $progress['total_rules'] ) {
			$progress['total_rules'] = count( $rule_ids );
		}

		$rules_remaining   = max( 0, count( $rule_ids ) - $rule_index );
		// Process more rules per chunk for better performance (was limit/2, now limit/1.5)
		$rules_to_process  = min( max( 1, (int) ceil( $limit / 1.5 ) ), $rules_remaining );

		// Optimize: Get data store instance once and query total cache rows at start of chunk
		// Only query cache count if we're actually processing items (not on every poll)
		$data_store = null;
		$cache_count_before = 0;
		if ( 0 < $rules_to_process && class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			$data_store = new WC_Appointments_Availability_Cache_Data_Store();
			global $wpdb;
			$cache_count_before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_appointments_availability_cache" );
		}

		for ( $i = 0; $i < $rules_to_process; $i++ ) {
			$idx = $rule_index + $i;
			if ( ! isset( $rule_ids[ $idx ] ) ) {
				break;
			}
			$rid = (int) $rule_ids[ $idx ];
			do_action( 'wc_appointments_index_availability_rule', $rid );
			$progress['processed_rules'] = (int) $progress['processed_rules'] + 1;
			$processed_this_call++;
		}
		$queue['rule_index'] = $rule_index + $rules_to_process;

		// Calculate cache rows created for rules in this chunk
		// Only query if we actually processed rules to avoid unnecessary queries
		if ( $data_store instanceof \WC_Appointments_Availability_Cache_Data_Store && 0 < $rules_to_process ) {
			global $wpdb;
			$cache_count_after = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_appointments_availability_cache" );
			$chunk_rows_created = max( 0, $cache_count_after - $cache_count_before );
			$progress['cache_rows_created'] = (int) $progress['cache_rows_created'] + $chunk_rows_created;
			$cache_count_before = $cache_count_after; // Update for next chunk
		}

		// Process appointments.
		$appt_ids   = (array) ( $queue['appt_ids'] ?? [] );
		$appt_index = (int) ( $queue['appt_index'] ?? 0 );

		$progress['total_appointments'] = (int) ( $progress['total_appointments'] ?? count( $appt_ids ) );
		if ( 0 === $progress['total_appointments'] ) {
			$progress['total_appointments'] = count( $appt_ids );
		}

		$appts_remaining  = max( 0, count( $appt_ids ) - $appt_index );
		$appts_to_process = min( max( 0, $limit - $processed_this_call ), $appts_remaining );

		// Use same data store instance and continue tracking cache count
		if ( ! $data_store && class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			$data_store = new WC_Appointments_Availability_Cache_Data_Store();
			if ( 0 === $cache_count_before ) {
				global $wpdb;
				$cache_count_before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_appointments_availability_cache" );
			}
		}

		for ( $i = 0; $i < $appts_to_process; $i++ ) {
			$idx = $appt_index + $i;
			if ( ! isset( $appt_ids[ $idx ] ) ) {
				break;
			}
			$aid = (int) $appt_ids[ $idx ];
			do_action( 'wc_appointments_index_appointment', $aid );
			// Ensure processed counter increments even if handler doesn't.
			$progress['processed_appts'] = (int) $progress['processed_appts'] + 1;
		}
		$queue['appt_index'] = $appt_index + $appts_to_process;

		// Calculate cache rows created for appointments in this chunk
		// Only query if we actually processed appointments to avoid unnecessary queries
		if ( $data_store instanceof \WC_Appointments_Availability_Cache_Data_Store && 0 < $appts_to_process ) {
			global $wpdb;
			$cache_count_after = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_appointments_availability_cache" );
			$chunk_rows_created = max( 0, $cache_count_after - $cache_count_before );
			$progress['cache_rows_created'] = (int) $progress['cache_rows_created'] + $chunk_rows_created;
		}

		// Completion check.
		$done_rules = ( $progress['processed_rules'] >= $progress['total_rules'] ) || ( count( $rule_ids ) <= $queue['rule_index'] );
		$done_appts = ( $progress['processed_appts'] >= $progress['total_appointments'] ) || ( count( $appt_ids ) <= $queue['appt_index'] );

		$progress['updated_at'] = time();
		if ( $done_rules && $done_appts ) {
			$progress['status']       = 'completed';
			$progress['completed_at']  = time();
			update_option( 'wc_appointments_last_index_completed', [
				'timestamp'                 => $progress['completed_at'],
				'total_rules'               => (int) $progress['total_rules'],
				'total_appointments'        => (int) $progress['total_appointments'],
				'current_run_rules'         => (int) $progress['processed_rules'],
				'current_run_appointments'  => (int) $progress['processed_appts'],
				'cache_rows_created'        => (int) $progress['cache_rows_created'],
			], false );
			delete_option( 'wc_appointments_index_queue' );
			// Clear the manual reindex flag on completion.
			delete_option( 'wc_appointments_manual_reindex_full_indexing' );
		} else {
			$progress['status'] = 'running';
			update_option( 'wc_appointments_index_queue', $queue, false );
		}

		update_option( 'wc_appointments_index_progress', $progress, false );

		wp_send_json_success( [ 'progress' => $progress ] );
	}

	/**
	 * Get appointable products for the modal dropdown
	 */
	public function get_appointable_products(): void {
		check_ajax_referer( 'get-appointable-products', 'nonce' );

		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_send_json_error( 'Insufficient permissions.' );
		}

		$products = [];

		// First, let's check all products and their _appointable meta
		$all_products_args = [
			'post_type'      => 'product',
			'post_status'    => 'publish',
			'posts_per_page' => -1,
		];
		$all_products_query = new WP_Query( $all_products_args );

		if ( $all_products_query->have_posts() ) {
			while ( $all_products_query->have_posts() ) {
				$all_products_query->the_post();
				$product_id = get_the_ID();
				$appointable_meta = get_post_meta( $product_id, '_appointable', true );
				$product_type = get_post_meta( $product_id, '_product_type', true );
			}
			wp_reset_postdata();
		}

		// Also check for appointment product type
		$appointment_type_args = [
			'post_type'      => 'product',
			'post_status'    => 'publish',
			'posts_per_page' => -1,
			'meta_query'     => [
				[
					'key'   => '_product_type',
					'value' => 'appointment',
				],
			],
		];
		$appointment_type_query = new WP_Query( $appointment_type_args );

		// Get all appointable products
		$args = [
			'post_type'      => 'product',
			'post_status'    => 'publish',
			'posts_per_page' => -1,
			'meta_query'     => [
				[
					'key'   => '_appointable',
					'value' => 'yes',
				],
			],
			'orderby'        => 'title',
			'order'          => 'ASC',
		];

		$query = new WP_Query( $args );

		if ( $query->have_posts() ) {
			while ( $query->have_posts() ) {
				$query->the_post();
				$product_id = get_the_ID();
				$product = wc_get_product( $product_id );

				if ( $product && $product->is_type( 'appointment' ) ) {
					$products[ $product_id ] = $product->get_name();
				}
			}
			wp_reset_postdata();
		}

		if ( [] === $products ) {
			// Send detailed debug info in the error response
			wp_send_json_error( [
				'message' => __( 'No appointable products found.', 'woocommerce-appointments' ),
				'debug' => [
					'total_products' => $all_products_query->found_posts,
					'appointment_type_products' => $appointment_type_query->found_posts,
					'appointable_query_posts' => $query->found_posts,
				],
			] );
		}

		wp_send_json_success( $products );
	}

	/**
	 * Search appointable products for AJAX dropdown
	 */
	public function search_appointable_products(): void {
		check_ajax_referer( 'search-appointable-products', 'security' );

		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_die( -1 );
		}

		$term = isset( $_GET['term'] ) ? sanitize_text_field( wp_unslash( $_GET['term'] ) ) : '';
		$limit = 50;

		if ( empty( $term ) ) {
			wp_die();
		}

		$products = [];

		// Search for appointment products
		$args = [
			'post_type'      => 'product',
			'post_status'    => 'publish',
			'posts_per_page' => $limit,
			's'              => $term,
			'meta_query'     => [
				[
					'key'     => '_appointable',
					'value'   => 'yes',
					'compare' => '=',
				],
			],
		];

		$query = new WP_Query( $args );

		if ( $query->have_posts() ) {
			while ( $query->have_posts() ) {
				$query->the_post();
				$product_id = get_the_ID();
				$product = wc_get_product( $product_id );

				if ( $product && $product->is_type( 'appointment' ) ) {
					$products[ $product_id ] = $product->get_formatted_name();
				}
			}
			wp_reset_postdata();
		}

		wp_send_json( $products );
	}

	/**
	 * Get product details for form configuration
	 */
	public function get_product_details(): void {
		check_ajax_referer( 'get-product-details', 'nonce' );

		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_die( -1 );
		}

		$product_id = absint( $_POST['product_id'] );
		$product = wc_get_product( $product_id );

		if ( ! $product || ! $product->is_type( 'appointment' ) ) {
			wp_send_json_error( __( 'Invalid product.', 'woocommerce-appointments' ) );
		}

		$product_data = [
			'has_time'       => $product->has_time(),
			'has_staff'        => $product->has_staff(),
			'staff_assignment' => $product->get_staff_assignment(),
			'staff_nopref'     => method_exists( $product, 'get_staff_nopref' ) ? $product->get_staff_nopref() : false,
			'staff'            => [],
			'duration'       => $product->get_duration(),
			'duration_unit'  => $product->get_duration_unit(),
			'interval'       => method_exists( $product, 'get_interval' ) ? $product->get_interval() : 0,
			'interval_unit'  => method_exists( $product, 'get_interval_unit' ) ? $product->get_interval_unit() : 'minute',
			'product_id'     => $product->get_id(),
			'formatted_name' => $product->get_formatted_name(),
		];

		// Base product price for initial display in admin modal.
		$base_price = (float) $product->get_price();
		if ( 'incl' === get_option( 'woocommerce_tax_display_shop' ) ) {
			$display_price = wc_get_price_including_tax( $product, [ 'price' => $base_price ] );
		} else {
			$display_price = wc_get_price_excluding_tax( $product, [ 'price' => $base_price ] );
		}
		$product_data['price_raw']  = (float) $display_price;
		$product_data['price_html'] = wc_price( $display_price );

		// Get staff members if product has staff
		if ( $product->has_staff() ) {
			$staff_ids = $product->get_staff_ids();
			if ( ! empty( $staff_ids ) ) {
				foreach ( $staff_ids as $staff_id ) {
					$staff = get_userdata( $staff_id );
					if ( $staff ) {
						$product_data['staff'][ $staff_id ] = $staff->display_name;
					}
				}
			}
		}

		wp_send_json_success( $product_data );
	}

	/**
	 * Get available times for a specific date and product
	 */
	public function get_available_times(): void {
		check_ajax_referer( 'get-available-times', 'nonce' );

		if ( ! current_user_can( 'edit_appointments' ) ) {
			wp_die( -1 );
		}

		$product_id = absint( $_POST['product_id'] );
		$date = sanitize_text_field( $_POST['date'] );
		$staff_id = empty( $_POST['staff_id'] ) ? 0 : absint( $_POST['staff_id'] );

		$product = wc_get_product( $product_id );

		if ( ! $product || ! $product->is_type( 'appointment' ) ) {
			wp_send_json_error( __( 'Invalid product.', 'woocommerce-appointments' ) );
		}

		if ( ! $product->has_time() ) {
			wp_send_json_success( [] );
		}

		// Convert date to timestamp
		$timestamp = strtotime( $date );
		if ( ! $timestamp ) {
			wp_send_json_error( __( 'Invalid date.', 'woocommerce-appointments' ) );
		}

		$available_times = [];

		// Get available slots for the date using cached/indexed availability
		$intervals = method_exists( $product, 'get_intervals' ) ? $product->get_intervals() : [];
		$slots = WC_Appointments_Controller::get_cached_slots_in_range(
		    $product,
		    $timestamp,
		    $timestamp + DAY_IN_SECONDS,
		    $intervals,
		    $staff_id,
		    [],
		    false,
		    false,
		);

		if ( ! empty( $slots ) ) {
			$tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( wc_timezone_string() );
			foreach ( $slots as $slot ) {
				$dt_local   = ( new DateTimeImmutable( '@' . $slot ) )->setTimezone( $tz );
				$time_value = $dt_local->format( 'H:i' );
				$time_label = date_i18n( get_option( 'time_format' ), $slot );
				$available_times[ $time_value ] = $time_label;
			}
		}

		wp_send_json_success( $available_times );
	}

	/**
	 * Check availability for current selection (server-side capacity and rules)
	 */
	public function check_availability(): void {
		check_ajax_referer( 'wc_appointments_check_availability', 'nonce' );

		if ( ! current_user_can( 'edit_appointments' ) ) {
			wp_die( -1 );
		}

		try {
			$product_id = absint( $_POST['product_id'] ?? $_POST['appointment_product_id'] ?? 0 );
			$staff_id   = absint( $_POST['staff_id'] ?? $_POST['appointment_staff_id'] ?? 0 );
			$qty        = absint( $_POST['quantity'] ?? 1 );
			$prefer_same_start = ! empty( $_POST['prefer_same_start_time'] );

			if ( ! $product_id ) {
				throw new Exception( __( 'Product is required.', 'woocommerce-appointments' ) );
			}

			$product = wc_get_product( $product_id );
			if ( ! $product || ! $product->is_type( 'appointment' ) ) {
				throw new Exception( __( 'Invalid product.', 'woocommerce-appointments' ) );
			}

			$single_date = sanitize_text_field( $_POST['appointment_date'] ?? $_POST['date'] ?? '' );
			$month_from  = sanitize_text_field( $_POST['appointment_month_from'] ?? '' );
			$month_to    = sanitize_text_field( $_POST['appointment_month_to'] ?? '' );
			$date_from   = sanitize_text_field( $_POST['appointment_date_from'] ?? '' );
			$date_to     = sanitize_text_field( $_POST['appointment_date_to'] ?? '' );
			$time_from   = sanitize_text_field( $_POST['appointment_time_from'] ?? $_POST['time_from'] ?? '' );
			$time_to     = sanitize_text_field( $_POST['appointment_time_to'] ?? $_POST['time_to'] ?? '' );

			$tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( wc_timezone_string() );

			// Build minimal payload for wc_appointments_get_posted_data().
			$posted_for_builder = [ 'quantity' => $qty ];

			if ( ! empty( $single_date ) ) {
				$dt = new DateTimeImmutable( $single_date . ' 00:00:00', $tz );
				$posted_for_builder['wc_appointments_field_start_date_year']  = $dt->format( 'Y' );
				$posted_for_builder['wc_appointments_field_start_date_month'] = $dt->format( 'm' );
				$posted_for_builder['wc_appointments_field_start_date_day']   = $dt->format( 'd' );
			} elseif ( ! empty( $month_from ) ) {
				$posted_for_builder['wc_appointments_field_start_date_yearmonth'] = $month_from; // YYYY-MM
			} elseif ( ! empty( $date_from ) ) {
				$dt = new DateTimeImmutable( $date_from . ' 00:00:00', $tz );
				$posted_for_builder['wc_appointments_field_start_date_year']  = $dt->format( 'Y' );
				$posted_for_builder['wc_appointments_field_start_date_month'] = $dt->format( 'm' );
				$posted_for_builder['wc_appointments_field_start_date_day']   = $dt->format( 'd' );
			} else {
				throw new Exception( __( 'Start date is required.', 'woocommerce-appointments' ) );
			}

			if ( $product->has_time() && ! empty( $time_from ) ) {
				$posted_for_builder['wc_appointments_field_start_date_time'] = $time_from;
			}
			if ( $product->has_staff() && $staff_id ) {
				$posted_for_builder['wc_appointments_field_staff'] = $staff_id;
			}

			$appointment_data = wc_appointments_get_posted_data( $posted_for_builder, $product );
			$appointment_data['_qty'] ??= $qty;
			$appointment_data['_staff_id'] ??= '';

			// Add-ons duration in minutes, included for combined duration checks
			$addons_duration = isset( $_POST['duration'] ) ? absint( $_POST['duration'] ) : 0;

			// If admin provided a specific time range on a day-based product,
			// align start/end to those times so time rules are considered.
			$has_admin_time_range = ! empty( $single_date ) && ( ! empty( $time_from ) || ! empty( $time_to ) );
			if ( ! $product->has_time() && $has_admin_time_range ) {
				if ( ! empty( $time_from ) ) {
					$appointment_data['_start_date'] = ( new DateTimeImmutable( $single_date . ' ' . $time_from, $tz ) )->getTimestamp();
				}
				if ( ! empty( $time_to ) ) {
					$end_ts = ( new DateTimeImmutable( $single_date . ' ' . $time_to, $tz ) )->getTimestamp();
					$start_baseline = isset( $appointment_data['_start_date'] ) ? (int) $appointment_data['_start_date'] : ( new DateTimeImmutable( $single_date . ' 00:00:00', $tz ) )->getTimestamp();
					if ( $end_ts <= $start_baseline ) {
						$end_ts = strtotime( '+1 day', $end_ts );
					}
					$appointment_data['_end_date'] = $end_ts;
				}
			}

			// If a custom end time is provided on a time-based product, clamp to combined duration (base + add-ons).
			if ( $product->has_time() && ! empty( $time_to ) && ! empty( $single_date ) ) {
				$start_ts = (int) $appointment_data['_start_date'];

				// Base duration (minutes) for time products and combined with add-ons.
				$base_unit     = $product->get_duration_unit();
				$base_duration = (int) $product->get_duration();
				$base_minutes  = ( 'hour' === $base_unit ) ? ( $base_duration * 60 ) : ( 'minute' === $base_unit ? $base_duration : 0 );
				$combined_minutes = max( 0, (int) ( $base_minutes + $addons_duration ) );
				$default_end_ts = $start_ts + ( $combined_minutes * 60 );

				// Admin-provided end timestamp.
				$end_ts = ( new DateTimeImmutable( $single_date . ' ' . $time_to, $tz ) )->getTimestamp();

				// Compare difference in minutes (rounded) to combined duration.
				$diff_minutes = (int) round( ( $end_ts - $start_ts ) / 60 );

				// Accept admin end only if same day and matches combined duration (±2 min).
				$tolerance = 2;
				$same_day  = ( (int) ( new DateTimeImmutable( '@' . $start_ts ) )->setTimezone( $tz )->format( 'Ymd' ) === (int) ( new DateTimeImmutable( '@' . $end_ts ) )->setTimezone( $tz )->format( 'Ymd' ) );
				$use_admin_end = $same_day && 0 < $combined_minutes && abs( $diff_minutes - $combined_minutes ) <= $tolerance;

				if ( ! $use_admin_end ) {
					$end_ts = $default_end_ts;
				}

				$appointment_data['_end_date'] = $end_ts;
			}

			// If time-based product and no end time provided, ensure end reflects combined duration
			if ( $product->has_time() && empty( $time_to ) && ! empty( $single_date ) ) {
				$start_ts = (int) $appointment_data['_start_date'];
				$base_unit     = $product->get_duration_unit();
				$base_duration = (int) $product->get_duration();
				$base_minutes  = ( 'hour' === $base_unit ) ? ( $base_duration * 60 ) : ( 'minute' === $base_unit ? $base_duration : 0 );
				$combined_minutes = max( 0, (int) ( $base_minutes + $addons_duration ) );
				$appointment_data['_end_date'] = $start_ts + ( $combined_minutes * 60 );
			}

			// For day/month-based products with range selection, honor inclusive end labels
			if ( ! $product->has_time() ) {
				if ( ! empty( $month_from ) && ! empty( $month_to ) ) {
					// Start at first day of from-month 00:00, end at first day of next month 00:00 (inclusive month_to)
					$appointment_data['_start_date'] = strtotime( $month_from . '-01 00:00:00' );
					$appointment_data['_end_date']   = strtotime( '+1 month', strtotime( $month_to . '-01 00:00:00' ) );
				} elseif ( ! empty( $date_from ) && ! empty( $date_to ) ) {
					// Start at from-date 00:00, end at start of next day 00:00 (inclusive date_to)
					$appointment_data['_start_date'] = strtotime( $date_from . ' 00:00:00' );
					$appointment_data['_end_date']   = strtotime( '+1 day', strtotime( $date_to . ' 00:00:00' ) );
				}
			}

			// Core appointability check (same pattern as admin add).
			$appointable_result = $product->is_appointable( $appointment_data );
			$appointable        = ! is_wp_error( $appointable_result );

			// Capacity calculation for the selected range.
			$available_total = wc_appointments_get_total_available_appointments_for_range(
			    $product,
			    $appointment_data['_start_date'],
			    $appointment_data['_end_date'],
			    $appointment_data['_staff_id'],
			    $appointment_data['_qty'],
			);

			$capacity_num = 0;
			if ( is_array( $available_total ) ) {
				foreach ( $available_total as $v ) {
					$capacity_num += max( 0, (int) $v );
				}
			} elseif ( ! is_wp_error( $available_total ) ) {
				$capacity_num = max( 0, (int) $available_total );
			}

			// Enforce time-based rules when admin specifies a time range on day-based products.
			$time_rules_ok = true;
			if ( ! $product->has_time() && $has_admin_time_range && ! empty( $appointment_data['_start_date'] ) && ! empty( $appointment_data['_end_date'] ) ) {
				$time_cap = WC_Product_Appointment_Rule_Manager::check_availability_rules_against_time(
				    $product,
				    $appointment_data['_start_date'],
				    $appointment_data['_end_date'],
				    $appointment_data['_staff_id'],
				    true,
				);
				$time_rules_ok = ( ! is_wp_error( $time_cap ) ) && (bool) $time_cap;
				if ( ! $time_rules_ok ) {
					$capacity_num = 0;
				}
			}

			// Final response
			$is_range_pick = ( ! $product->has_time() ) && ( ( ! empty( $month_from ) && ! empty( $month_to ) ) || ( ! empty( $date_from ) && ! empty( $date_to ) ) );
			$response = [
				// For day/month range selections, rely on capacity across the range.
				// For single date/time selections, keep appointable gate in place.
				'available' => $is_range_pick ? ( 0 < $capacity_num && $time_rules_ok ) : ( $appointable && 0 < $capacity_num && $time_rules_ok ),
				'capacity'  => $capacity_num,
				'start'     => (int) $appointment_data['_start_date'],
				'end'       => (int) $appointment_data['_end_date'],
			];

			// Suggest next available slot if unavailable.
			if ( ! $response['available'] ) {
				$unit      = $product->get_duration_unit();
				$intervals = method_exists( $product, 'get_intervals' ) ? $product->get_intervals() : [];
				$search_to = $appointment_data['_end_date'];

				if ( 'month' === $unit ) {
					$search_to = strtotime( '+12 months', $appointment_data['_start_date'] );
				} elseif ( 'day' === $unit ) {
					$search_to = strtotime( '+60 days', $appointment_data['_start_date'] );
				} else {
					$search_to = strtotime( '+14 days', $appointment_data['_start_date'] );
				}

				$slots = WC_Appointments_Controller::get_cached_slots_in_range(
				    $product,
				    $appointment_data['_start_date'],
				    $search_to,
				    $intervals,
				    $appointment_data['_staff_id'],
				    [],
				    false,
				    true,
				);

				if ( ! empty( $slots ) ) {
					$available_slots = WC_Appointments_Controller::get_cached_time_slots( $product, [
						'slots'     => $slots,
						'intervals' => $intervals,
						'staff_id'  => $appointment_data['_staff_id'],
						'from'      => $appointment_data['_start_date'],
						'to'        => $search_to,
					] );

					$next_start      = 0;
					$next_same_start = 0;
					$next_same_day   = 0;
					$target_minutes  = null;
					$duration        = max( 0, (int) ( $appointment_data['_end_date'] - $appointment_data['_start_date'] ) );

					// Compare in store timezone for same-day prioritization
					$start_day_local = ( new DateTimeImmutable( '@' . $appointment_data['_start_date'] ) )->setTimezone( $tz )->format( 'Y-m-d' );

					if ( $prefer_same_start && $product->has_time() && ! empty( $time_from ) ) {
						$parts = explode( ':', $time_from );
						$h     = isset( $parts[0] ) ? (int) $parts[0] : 0;
						$m     = isset( $parts[1] ) ? (int) $parts[1] : 0;
						$target_minutes = ( $h * 60 ) + $m;
					}

					if ( is_array( $available_slots ) ) {
						ksort( $available_slots, SORT_NUMERIC );
					}

					foreach ( $available_slots as $slot_ts => $quantity ) {
						if ( $slot_ts <= $appointment_data['_start_date'] ) {
							continue;
						}
						$avail_all   = isset( $quantity['available'] ) ? (int) $quantity['available'] : 0;
						$avail_staff = $avail_all;
						if ( $appointment_data['_staff_id'] && isset( $quantity['staff'] ) && is_array( $quantity['staff'] ) ) {
							$avail_staff = isset( $quantity['staff'][ $appointment_data['_staff_id'] ] ) ? (int) $quantity['staff'][ $appointment_data['_staff_id'] ] : 0;
						}
						if ( 0 < $avail_all && 0 < $avail_staff ) {
							$candidate_end = 0 < $duration ? ( $slot_ts + $duration ) : 0;
							$range_ok      = $candidate_end > $slot_ts ? wc_appointments_get_total_available_appointments_for_range( $product, $slot_ts, $candidate_end, $appointment_data['_staff_id'], $appointment_data['_qty'] ) : 1;
							if ( ! is_wp_error( $range_ok ) && (int) $range_ok > 0 ) {
								if ( 0 === $next_start ) {
									$next_start = (int) $slot_ts;
								}
								// Track first next slot on the same local day as the original request
								if ( 0 === $next_same_day ) {
									$slot_day_local = ( new DateTimeImmutable( '@' . $slot_ts ) )->setTimezone( $tz )->format( 'Y-m-d' );
									if ( $slot_day_local === $start_day_local ) {
										$next_same_day = (int) $slot_ts;
									}
								}
								if ( null !== $target_minutes ) {
									$slot_dt      = new DateTimeImmutable( '@' . $slot_ts );
									$slot_dt      = $slot_dt->setTimezone( $tz );
									$slot_minutes = ( (int) $slot_dt->format( 'H' ) * 60 ) + (int) $slot_dt->format( 'i' );
									if ( $slot_minutes === $target_minutes ) {
										$next_same_start = (int) $slot_ts;
										break;
									}
								}
							}
						}
					}

					if ( 0 !== $next_same_start ) {
						$response['next_same_start'] = [ 'start' => $next_same_start ];
						if ( 0 < $duration ) {
							$response['next_same_start']['end'] = $next_same_start + $duration;
						}
					}
					// Prefer a next slot on the same local day; otherwise fallback to first available
					$preferred_next = 0;
					if ( 0 !== $next_same_start ) {
						$next_same_start_day = ( new DateTimeImmutable( '@' . $next_same_start ) )->setTimezone( $tz )->format( 'Y-m-d' );
						if ( $next_same_start_day === $start_day_local ) {
							$preferred_next = $next_same_start;
						}
					}
					if ( ! $preferred_next && $next_same_day ) {
						$preferred_next = $next_same_day;
					}
					if ( ! $preferred_next && $next_start ) {
						$preferred_next = $next_start;
					}
					if ( 0 !== $preferred_next ) {
						$response['next'] = [ 'start' => $preferred_next ];
						if ( 0 < $duration ) {
							$response['next']['end'] = $preferred_next + $duration;
						}
					}
				}
			}

			wp_send_json_success( $response );

		} catch ( Exception $e ) {
			wp_send_json_error( $e->getMessage() );
		}
	}

	/**
	 * Create a new appointment
	 */
	public function create_appointment(): void {
		check_ajax_referer( 'create-appointment', 'nonce' );

		if ( ! current_user_can( 'edit_appointments' ) ) {
			wp_die( -1 );
		}

		// Set a flag to prevent currency conversion in admin
		// This ensures appointment and order costs match in base currency
		// create_appointment is admin-only (no nopriv handler), so always set the flag
		if ( ! defined( 'WC_APPOINTMENTS_ADMIN_CREATE' ) ) {
			define( 'WC_APPOINTMENTS_ADMIN_CREATE', true );
		}

		try {
			// Sanitize and validate input
			$product_id  = absint( $_POST['appointment_product_id'] );
			$staff_id    = empty( $_POST['appointment_staff_id'] ) ? 0 : absint( $_POST['appointment_staff_id'] );
			$customer_id = empty( $_POST['appointment_customer_id'] ) ? 0 : absint( $_POST['appointment_customer_id'] );
			$order_type  = sanitize_text_field( $_POST['order_type'] );
			$order_id    = empty( $_POST['appointment_order_id'] ) ? 0 : absint( $_POST['appointment_order_id'] );
			$status      = sanitize_text_field( $_POST['appointment_status'] );
			$qty         = empty( $_POST['quantity'] ) ? 1 : absint( $_POST['quantity'] );
			$create_account = ! empty( $_POST['appointment_create_account'] );
			$customer_status = isset( $_POST['appointment_customer_status'] ) ? sanitize_text_field( $_POST['appointment_customer_status'] ) : '';

			// Range-aware fields
			$single_date = empty( $_POST['appointment_date'] ) ? '' : sanitize_text_field( $_POST['appointment_date'] );
			$time_from   = empty( $_POST['appointment_time_from'] ) ? '' : sanitize_text_field( $_POST['appointment_time_from'] );
			$time_to     = empty( $_POST['appointment_time_to'] ) ? '' : sanitize_text_field( $_POST['appointment_time_to'] );
			$date_from   = empty( $_POST['appointment_date_from'] ) ? '' : sanitize_text_field( $_POST['appointment_date_from'] );
			$date_to     = empty( $_POST['appointment_date_to'] ) ? '' : sanitize_text_field( $_POST['appointment_date_to'] );
			$month_from  = empty( $_POST['appointment_month_from'] ) ? '' : sanitize_text_field( $_POST['appointment_month_from'] );
			$month_to    = empty( $_POST['appointment_month_to'] ) ? '' : sanitize_text_field( $_POST['appointment_month_to'] );

			if ( ! $product_id ) {
				throw new Exception( __( 'Product is required.', 'woocommerce-appointments' ) );
			}

			$product = wc_get_product( $product_id );
			if ( ! $product || ! $product->is_type( 'appointment' ) ) {
				throw new Exception( __( 'Invalid product.', 'woocommerce-appointments' ) );
			}

			if ( ! $customer_id && $create_account && 'existing' !== $order_type ) {
				$billing_email = isset( $_POST['_billing_email'] ) ? sanitize_email( wp_unslash( $_POST['_billing_email'] ) ) : '';
				if ( ! $billing_email || ! is_email( $billing_email ) ) {
					throw new Exception( __( 'A valid billing email is required to create an account.', 'woocommerce-appointments' ) );
				}

				$existing_user = get_user_by( 'email', $billing_email );
				if ( $existing_user && $existing_user->ID ) {
					$customer_id = (int) $existing_user->ID;
					
					// Update existing customer's billing data if provided
					$this->update_customer_billing_data( $customer_id, $_POST );
				} else {
					// Collect all billing fields for customer creation
					$billing_first_name = isset( $_POST['_billing_first_name'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_first_name'] ) ) : '';
					$billing_last_name  = isset( $_POST['_billing_last_name'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_last_name'] ) ) : '';

					$username = '';
					$password = '';

					$customer_id = wc_create_new_customer(
					    $billing_email,
					    $username,
					    $password,
					    [
							'first_name' => $billing_first_name,
							'last_name'  => $billing_last_name,
						],
					);

					if ( is_wp_error( $customer_id ) ) {
						throw new Exception( $customer_id->get_error_message() );
					}

					// Save all billing data to the new customer account (similar to WooCommerce checkout)
					$this->update_customer_billing_data( $customer_id, $_POST );
				}
			}

			// Update existing customer's billing data if customer is selected and billing fields are provided.
			// This ensures customer data is saved for future use, even if they already exist.
			if ( $customer_id > 0 ) {
				// Check if any billing fields are provided in the form
				$has_billing_data = false;
				$billing_fields = [
					'_billing_first_name',
					'_billing_last_name',
					'_billing_company',
					'_billing_address_1',
					'_billing_address_2',
					'_billing_city',
					'_billing_postcode',
					'_billing_country',
					'_billing_state',
					'_billing_email',
					'_billing_phone',
				];

				foreach ( $billing_fields as $field ) {
					if ( isset( $_POST[ $field ] ) && ! empty( $_POST[ $field ] ) ) {
						$has_billing_data = true;
						break;
					}
				}

				// Update customer billing data if any billing fields are provided
				if ( $has_billing_data ) {
					$this->update_customer_billing_data( $customer_id, $_POST );
				}
			}

			// Compute start/end timestamps based on picker style
			$start_timestamp = 0;
			$end_timestamp   = 0;

			if ( $product->has_time() ) {
				if ( empty( $single_date ) || empty( $time_from ) || empty( $time_to ) ) {
					throw new Exception( __( 'Date and start/end times are required.', 'woocommerce-appointments' ) );
				}
				$start_timestamp = strtotime( $single_date . ' ' . $time_from );
				$end_timestamp   = strtotime( $single_date . ' ' . $time_to );
				if ( ! $start_timestamp || ! $end_timestamp ) {
					throw new Exception( __( 'Invalid time range.', 'woocommerce-appointments' ) );
				}
				// Allow overnight end times: if end <= start, interpret as next-day
				if ( $end_timestamp <= $start_timestamp ) {
					$end_timestamp = strtotime( '+1 day', $end_timestamp );
				}
				if ( $end_timestamp <= $start_timestamp ) {
					throw new Exception( __( 'Invalid time range.', 'woocommerce-appointments' ) );
				}
			} else {
				if ( ! empty( $month_from ) && ! empty( $month_to ) ) {
					$start_timestamp = strtotime( $month_from . '-01 00:00' );
					// Treat end month as inclusive: boundary is the first day of the next month at 00:00
					$end_timestamp   = strtotime( '+1 month', strtotime( $month_to . '-01 00:00' ) ) - 1;
				} elseif ( ! empty( $date_from ) && ! empty( $date_to ) ) {
					$start_timestamp = strtotime( $date_from . ' 00:00' );
					// Treat end date as inclusive: boundary is the start of the next day at 00:00
					$end_timestamp   = strtotime( '+1 day', strtotime( $date_to . ' 00:00' ) ) - 1;
				} elseif ( ! empty( $single_date ) ) {
					// Fallback: single all-day date
					$start_timestamp = strtotime( $single_date . ' 00:00' );
					$end_timestamp   = strtotime( '+1 day', $start_timestamp );
				} else {
					throw new Exception( __( 'Start and end dates are required.', 'woocommerce-appointments' ) );
				}
				if ( ! $start_timestamp || ! $end_timestamp || $end_timestamp <= $start_timestamp ) {
					throw new Exception( __( 'Invalid date range.', 'woocommerce-appointments' ) );
				}
			}

			// Handle order creation/selection
			if ( 'existing' === $order_type && $order_id ) {
				$order = wc_get_order( $order_id );
				if ( ! $order ) {
					throw new Exception( __( 'Invalid order.', 'woocommerce-appointments' ) );
				}
            } else {
                // Create new order
                // Do not set default status explicitly; let WooCommerce defaults apply
                $order = wc_create_order( [
                    'customer_id' => $customer_id,
                ] );

				if ( is_wp_error( $order ) ) {
					throw new Exception( __( 'Failed to create order.', 'woocommerce-appointments' ) );
				}

                $order_id = $order->get_id();

				do_action( 'woocommerce_new_appointment_order', $order_id );

                // Apply billing details from the modal
                $billing = [
                    'first_name' => isset( $_POST['_billing_first_name'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_first_name'] ) ) : '',
                    'last_name'  => isset( $_POST['_billing_last_name'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_last_name'] ) ) : '',
					'company'    => isset( $_POST['_billing_company'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_company'] ) ) : '',
					'address_1'  => isset( $_POST['_billing_address_1'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_address_1'] ) ) : '',
					'address_2'  => isset( $_POST['_billing_address_2'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_address_2'] ) ) : '',
					'city'       => isset( $_POST['_billing_city'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_city'] ) ) : '',
					'postcode'   => isset( $_POST['_billing_postcode'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_postcode'] ) ) : '',
					'country'    => isset( $_POST['_billing_country'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_POST['_billing_country'] ) ) ) : '',
					'state'      => isset( $_POST['_billing_state'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_state'] ) ) : '',
					'email'      => isset( $_POST['_billing_email'] ) ? sanitize_email( wp_unslash( $_POST['_billing_email'] ) ) : '',
					'phone'      => isset( $_POST['_billing_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['_billing_phone'] ) ) : '',
                ];
                $order->set_address( $billing, 'billing' );

                // Mark created via Appointments for downstream logic and reporting
                $order->set_created_via( 'appointments' );

                // Optional transaction ID
                if ( isset( $_POST['_transaction_id'] ) ) {
                    $transaction_id = wc_clean( wp_unslash( $_POST['_transaction_id'] ) );
                    if ( '' !== $transaction_id ) {
                        // Use set_props for broad WooCommerce compatibility
                        $order->set_props( [ 'transaction_id' => $transaction_id ] );
                    }
                }

                // Optional payment method selection
                if ( isset( $_POST['_payment_method'] ) ) {
                    $payment_method = wc_clean( wp_unslash( $_POST['_payment_method'] ) );
                    if ( '' !== $payment_method ) {
                        $methods = WC()->payment_gateways() ? WC()->payment_gateways->payment_gateways() : [];
                        if ( isset( $methods[ $payment_method ] ) ) {
                            // Known gateway: set via object to populate id and title
                            $order->set_payment_method( $methods[ $payment_method ] );
                        } else {
                            // Unknown or "other": set both id and title explicitly
                            $order->set_props( [
                                'payment_method'       => $payment_method,
                                'payment_method_title' => ( 'other' === $payment_method ) ? __( 'Other', 'woocommerce-appointments' ) : $payment_method,
                            ] );
                        }
                    }
                }

            }

			// Determine staff assignment when none selected, mirroring frontend behavior
			$staff_ids_to_assign = [];
			if ( $product->has_staff() ) {
				if ( $staff_id ) {
					// Staff explicitly selected by admin
					$staff_ids_to_assign = [ $staff_id ];
				} else {
					// No staff selected - check assignment type and no preference setting
					$assignment_type = $product->get_staff_assignment();
					$staff_nopref = method_exists( $product, 'get_staff_nopref' ) ? $product->get_staff_nopref() : false;
					
					if ( 'all' === $assignment_type ) {
						// All staff together - assign all staff
						$staff_ids_to_assign = $product->get_staff_ids();
					} else {
						// "No preference" or automatic assignment - randomly select available staff
						// This mirrors frontend behavior where "no preference" means randomly assign any available staff
						$available = wc_appointments_get_total_available_appointments_for_range(
						    $product,
						    $start_timestamp,
						    $end_timestamp,
						    0,
						    1,
						);
						if ( is_array( $available ) && [] !== $available ) {
							$keys = array_keys( $available );
							shuffle( $keys );
							$staff_id = (int) reset( $keys );
							if ( 0 !== $staff_id ) {
								$staff_ids_to_assign = [ $staff_id ];
							}
						} else {
							// Fallback: randomly select from all product staff if no availability data
							$product_staff_ids = $product->get_staff_ids();
							if ( ! empty( $product_staff_ids ) ) {
								shuffle( $product_staff_ids );
								$staff_id = (int) reset( $product_staff_ids );
								$staff_ids_to_assign = [ $staff_id ];
							}
						}
					}
				}
			}

			// Normalize staff_ids - ensure it's in the correct format for the appointment object.
			// The appointment object stores staff_ids as string (comma-separated) or array.
			// For new appointments, we want to avoid triggering staff transitions from empty to assigned.
			// Convert empty arrays to empty string, and ensure arrays are properly formatted.
			$normalized_staff_ids = '';
			if ( ! empty( $staff_ids_to_assign ) ) {
				if ( is_array( $staff_ids_to_assign ) ) {
					// Filter out any empty values and ensure all are integers
					$staff_ids_to_assign = array_filter( array_map( 'intval', $staff_ids_to_assign ) );
					if ( ! empty( $staff_ids_to_assign ) ) {
						$normalized_staff_ids = array_values( $staff_ids_to_assign ); // Re-index array
					}
				} else {
					// Single value, convert to array with single element
					$staff_id = (int) $staff_ids_to_assign;
					if ( $staff_id > 0 ) {
						$normalized_staff_ids = [ $staff_id ];
					}
				}
			}
			// If empty after normalization, use empty string (not empty array) to match default data structure

			// Prepare appointment data but defer saving until everything is set,
			// so events are scheduled only once.
			$appointment_data = [
				'product_id'  => $product_id,
				'start'       => $start_timestamp,
				'end'         => $end_timestamp,
				'all_day'     => ! $product->has_time(),
				'order_id'    => $order_id,
				'customer_id' => $customer_id,
				'staff_ids'   => $normalized_staff_ids,
				'status'      => $status,
			];

			// Acquire booking lock to prevent race conditions (admin can also create concurrent bookings).
			$lock_acquired = wc_appointments_acquire_booking_lock( $product_id, $start_timestamp, $end_timestamp, $staff_ids_to_assign );

			if ( ! $lock_acquired ) {
				// Another booking is in progress for this slot, wait briefly and retry once.
				usleep( 100000 ); // 100ms delay.
				$lock_acquired = wc_appointments_acquire_booking_lock( $product_id, $start_timestamp, $end_timestamp, $staff_ids_to_assign );

				if ( ! $lock_acquired ) {
					wp_send_json_error( [ 'message' => __( 'This time slot is currently being processed. Please try again in a moment.', 'woocommerce-appointments' ) ] );
					return;
				}
			}

			$appointment = new WC_Appointment( $appointment_data );

			// Set qty and customer status on the object early, but do not save yet.
			if ( isset( $qty ) ) {
				$appointment->set_qty( max( 1, (int) $qty ) );
			}
			if ( ! empty( $customer_status ) ) {
				$appointment->set_customer_status( $customer_status );
			}

			// Determine final cost (override or calculated)
			$override_str  = isset( $_POST['appointment_override_price'] ) ? wc_clean( wp_unslash( $_POST['appointment_override_price'] ) ) : '';
			$override_price = '' !== $override_str ? wc_format_decimal( $override_str ) : '';
			$final_cost     = null;
			$override_includes_tax = false;

			if ( '' !== $override_price ) {
				// The override price is the display price from calculate_costs, which may include tax
				// This is the FINAL calculated appointment cost shown in the footer
				$tax_display_shop = get_option( 'woocommerce_tax_display_shop', 'excl' );
				$override_includes_tax = ( 'incl' === $tax_display_shop );

				// If override includes tax, extract the base cost
				if ( $override_includes_tax ) {
					$tax_class = $product->get_tax_class();
					$tax_rates = WC_Tax::get_base_tax_rates( $tax_class );
					$taxes_temp = WC_Tax::calc_tax( $override_price, $tax_rates, true );
					$total_tax = array_sum( $taxes_temp );
					$final_cost = max( 0, (float) $override_price - $total_tax );
				} else {
					$final_cost = max( 0, (float) $override_price );
				}

				$use_override_as_final = true;
			} else {
				$use_override_as_final = false;
				$cost_posted = [
					'wc_appointments_field_start_date_year'  => (int) date( 'Y', $start_timestamp ),
					'wc_appointments_field_start_date_month' => (int) date( 'm', $start_timestamp ),
					'wc_appointments_field_start_date_day'   => (int) date( 'd', $start_timestamp ),
					'quantity'                                => max( 1, (int) $qty ),
				];
				if ( $product->has_time() && ! empty( $time_from ) ) {
					$cost_posted['wc_appointments_field_start_date_time'] = $time_from;
				}
				if ( $staff_id ) {
					$cost_posted['wc_appointments_field_staff'] = $staff_id;
				}
				$raw_cost  = WC_Appointments_Cost_Calculation::calculate_appointment_cost( $cost_posted, $product );
				$final_cost = is_wp_error( $raw_cost ) ? 0 : (float) $raw_cost;
			}

			// Add product to order and set item totals
			$item_id = $order->add_product( $product, max( 1, (int) $qty ) );

			if ( ! $item_id ) {
				throw new Exception( __( 'Failed to add product to order.', 'woocommerce-appointments' ) );
			}

			$item = $order->get_item( $item_id );
			if ( ! $item || ! is_a( $item, 'WC_Order_Item_Product' ) ) {
				throw new Exception( __( 'Failed to add product to order.', 'woocommerce-appointments' ) );
			}

			// Handle Product Add-Ons if present
			$addon_cart   = $GLOBALS['Product_Addon_Cart'] ?? null;
			$addons_values = [];
			$addons_total  = 0.0;

			if ( $addon_cart && is_callable( [ $addon_cart, 'add_cart_item_data' ] ) ) {
				$addons_values = $addon_cart->add_cart_item_data( [], $product_id, $_POST );
			}

			if ( ! empty( $addons_values['addons'] ) ) {
				$item_data = [
					'product_id'   => $product_id,
					'variation_id' => 0,
					'quantity'     => max( 1, (int) $qty ),
					'data'         => $product,
					'nyp'          => $final_cost,
				];
				$addon_cart->order_line_item( $item, null, array_merge( $item_data, $addons_values ) );
				$addons_total = (float) $item->get_meta( '_pao_total', true );
			}

			// Get tax rates for the product's tax class
			$tax_class = $product->get_tax_class();
			$tax_rates = WC_Tax::get_base_tax_rates( $tax_class );

			// Calculate cost and taxes
			if ( $use_override_as_final ) {
				// Override price is the final calculated cost - use the base cost we already extracted
				$total_cost = $final_cost;
				$taxes = WC_Tax::calc_tax( $total_cost, $tax_rates, false );
			} else {
				// Calculate total cost (appointment cost + add-ons) and tax
				$total_cost = $final_cost + $addons_total;
				$taxes = WC_Tax::calc_tax( $total_cost, $tax_rates, false );
			}

			// Set line item totals (excluding tax)
			$item->set_subtotal( $total_cost );
			$item->set_total( $total_cost );

			// Set taxes on the line item
			if ( ! empty( $taxes ) ) {
				$item->set_taxes( [ 'total' => $taxes, 'subtotal' => $taxes ] );
			} else {
				$item->set_taxes( false );
			}

			$item->save();

			// Set the appointment cost
			$appointment->set_cost( $total_cost );
			// Link appointment to order item
			$appointment->set_order_item_id( $item_id );
			$appointment_id = $appointment->save();
			if ( 0 === $appointment_id ) {
				wc_appointments_release_booking_lock( $product_id, $start_timestamp, $end_timestamp, $staff_ids_to_assign );
				throw new Exception( __( 'Failed to create appointment.', 'woocommerce-appointments' ) );
			}

			// Release lock on success
			wc_appointments_release_booking_lock( $product_id, $start_timestamp, $end_timestamp, $staff_ids_to_assign );

			// Persist appointment_id on the order item meta for compatibility
			$item->add_meta_data( 'appointment_id', $appointment_id, true );
			$item->save();

			// Calculate order totals and create tax line items
			// We need to call calculate_totals() to create tax line items for proper display
			// but then restore our calculated values
			$original_product_price = $product->get_price( 'edit' );
			if ( 0 < $total_cost ) {
				$product->set_price( $total_cost / max( 1, (int) $qty ) );
			}

			$order->calculate_totals();

			// Restore original product price
			if ( 0 < $total_cost ) {
				$product->set_price( $original_product_price );
			}

			// Restore our calculated item values
			$item = $order->get_item( $item_id );
			if ( $item ) {
				$item->set_subtotal( $total_cost );
				$item->set_total( $total_cost );
				if ( ! empty( $taxes ) ) {
					$item->set_taxes( [ 'total' => $taxes, 'subtotal' => $taxes ] );
				}
				$item->save();
			}

			// Update tax line items with correct amounts
			foreach ( $order->get_items( 'tax' ) as $tax_item ) {
				$tax_rate_id = $tax_item->get_rate_id();
				if ( isset( $taxes[ $tax_rate_id ] ) ) {
					$tax_item->set_tax_total( $taxes[ $tax_rate_id ] );
					$tax_item->set_shipping_tax_total( 0 );
					$tax_item->save();
				}
			}

			// Calculate and set order totals from all items
			$order_subtotal = $total_cost;
			$order_tax_total = array_sum( $taxes );

			foreach ( $order->get_items() as $order_item ) {
				if ( is_a( $order_item, 'WC_Order_Item_Product' ) && $order_item->get_id() != $item_id ) {
					$order_subtotal += $order_item->get_total();
					$order_tax_total += $order_item->get_total_tax();
				}
			}

			foreach ( $order->get_items( 'shipping' ) as $shipping_item ) {
				$order_subtotal += $shipping_item->get_total();
				$order_tax_total += $shipping_item->get_total_tax();
			}

			foreach ( $order->get_items( 'fee' ) as $fee_item ) {
				$order_subtotal += $fee_item->get_total();
				$order_tax_total += $fee_item->get_total_tax();
			}

			$order->set_props( [
				'subtotal' => $order_subtotal,
				'total_tax' => $order_tax_total,
				'total' => $order_subtotal + $order_tax_total,
			] );

			$order->save();

			// Send success response
			wp_send_json_success( [
				'appointment_id' => $appointment_id,
				'order_id' => $order_id,
				'message' => __( 'Appointment created successfully.', 'woocommerce-appointments' ),
			] );

		} catch ( Exception $e ) {
			// Release lock on error if it was acquired.
			if ( isset( $lock_acquired ) && $lock_acquired && isset( $product_id ) && isset( $start_timestamp ) && isset( $end_timestamp ) ) {
				wc_appointments_release_booking_lock( $product_id, $start_timestamp, $end_timestamp, $staff_ids_to_assign ?? 0 );
			}
			wp_send_json_error( $e->getMessage() );
		}
	}

	public function get_customer_details(): void {
		check_ajax_referer( 'get-customer-details', 'security' );

		if ( ! current_user_can( 'edit_shop_orders' ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( __( 'Unauthorized', 'woocommerce-appointments' ), 403 );
		}

		$user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
		if ( ! $user_id ) {
			wp_send_json_error( __( 'Invalid user.', 'woocommerce-appointments' ), 400 );
		}

		$user = get_userdata( $user_id );
		$data = [
			'first_name'       => get_user_meta( $user_id, 'billing_first_name', true ),
			'last_name'        => get_user_meta( $user_id, 'billing_last_name', true ),
			'company'          => get_user_meta( $user_id, 'billing_company', true ),
			'address_1'        => get_user_meta( $user_id, 'billing_address_1', true ),
			'address_2'        => get_user_meta( $user_id, 'billing_address_2', true ),
			'city'             => get_user_meta( $user_id, 'billing_city', true ),
			'postcode'         => get_user_meta( $user_id, 'billing_postcode', true ),
			'country'          => get_user_meta( $user_id, 'billing_country', true ),
			'state'            => get_user_meta( $user_id, 'billing_state', true ),
			'email'            => get_user_meta( $user_id, 'billing_email', true ),
			'phone'            => get_user_meta( $user_id, 'billing_phone', true ),
			'user_first_name'  => get_user_meta( $user_id, 'first_name', true ),
			'user_last_name'   => get_user_meta( $user_id, 'last_name', true ),
			'display_name'     => $user ? $user->display_name : '',
		];
		$full_name = trim( ( $data['user_first_name'] ?: '' ) . ' ' . ( $data['user_last_name'] ?: '' ) );
		if ( ( '' === $full_name || '0' === $full_name ) && $user ) {
			$full_name = $user->display_name;
		}
		$data['name'] = $full_name;
		if ( empty( $data['email'] ) && $user ) {
			$data['email'] = $user->user_email;
		}
		$data = array_map( 'wc_clean', $data );
		wp_send_json_success( $data );
	}

	/**
	 * Return pretty labels for a list of minute durations using wc_appointment_pretty_timestamp.
	 */
	public function pretty_duration(): void {
		check_ajax_referer( 'wc_appointments_pretty_duration', 'security' );

		$raw  = isset( $_POST['durations'] ) ? wp_unslash( $_POST['durations'] ) : '';
		$unit = isset( $_POST['duration_unit'] ) ? sanitize_text_field( wp_unslash( $_POST['duration_unit'] ) ) : 'minute';

		$durations = json_decode( $raw, true );
		if ( ! is_array( $durations ) ) {
			$durations = array_filter( array_map( 'absint', explode( ',', (string) $raw ) ) );
		} else {
			$durations = array_map( 'absint', $durations );
		}

		$labels = [];
		foreach ( $durations as $d ) {
			$labels[] = WC_Appointment_Duration::format_minutes( $d, $unit );
		}

		wp_send_json_success( [ 'labels' => $labels ] );
	}

	/**
	 * Update customer billing data from POST data.
	 *
	 * Saves all billing fields to user meta, similar to WooCommerce checkout.
	 * This ensures customer data can be reused for future orders.
	 *
	 * @since 5.1.0
	 *
	 * @param int   $customer_id Customer/User ID.
	 * @param array $post_data   POST data array containing billing fields.
	 *
	 * @return void
	 */
	private function update_customer_billing_data( int $customer_id, array $post_data ): void {
		// Map POST field names to user meta keys
		$billing_fields = [
			'_billing_first_name' => 'billing_first_name',
			'_billing_last_name'  => 'billing_last_name',
			'_billing_company'    => 'billing_company',
			'_billing_address_1'  => 'billing_address_1',
			'_billing_address_2'  => 'billing_address_2',
			'_billing_city'       => 'billing_city',
			'_billing_postcode'   => 'billing_postcode',
			'_billing_country'    => 'billing_country',
			'_billing_state'      => 'billing_state',
			'_billing_email'      => 'billing_email',
			'_billing_phone'      => 'billing_phone',
		];

		// Save each billing field to user meta
		foreach ( $billing_fields as $post_key => $meta_key ) {
			if ( isset( $post_data[ $post_key ] ) ) {
				$value = '';
				
				// Sanitize based on field type
				if ( '_billing_email' === $post_key ) {
					$value = sanitize_email( wp_unslash( $post_data[ $post_key ] ) );
				} elseif ( '_billing_country' === $post_key ) {
					$value = strtoupper( sanitize_text_field( wp_unslash( $post_data[ $post_key ] ) ) );
				} else {
					$value = sanitize_text_field( wp_unslash( $post_data[ $post_key ] ) );
				}

				// Only update if value is not empty (preserve existing data if field is empty)
				if ( ! empty( $value ) ) {
					update_user_meta( $customer_id, $meta_key, $value );
				}
			}
		}

		// Also update first_name and last_name user meta (used by WordPress and WooCommerce)
		if ( isset( $post_data['_billing_first_name'] ) && ! empty( $post_data['_billing_first_name'] ) ) {
			update_user_meta( $customer_id, 'first_name', sanitize_text_field( wp_unslash( $post_data['_billing_first_name'] ) ) );
		}
		if ( isset( $post_data['_billing_last_name'] ) && ! empty( $post_data['_billing_last_name'] ) ) {
			update_user_meta( $customer_id, 'last_name', sanitize_text_field( wp_unslash( $post_data['_billing_last_name'] ) ) );
		}

		// Update display name if first and last name are available
		if ( isset( $post_data['_billing_first_name'] ) && isset( $post_data['_billing_last_name'] ) ) {
			$first_name = sanitize_text_field( wp_unslash( $post_data['_billing_first_name'] ) );
			$last_name  = sanitize_text_field( wp_unslash( $post_data['_billing_last_name'] ) );
			
			if ( ! empty( $first_name ) && ! empty( $last_name ) ) {
				wp_update_user(
					[
						'ID'           => $customer_id,
						'display_name' => trim( $first_name . ' ' . $last_name ),
					]
				);
			}
		}
	}

	/**
	 * Save user's preferred calendar view.
	 */
	public function save_calendar_view(): void {
		check_ajax_referer( 'wc_appointments_calendar_view', 'security' );

		$view = isset( $_POST['view'] ) ? sanitize_text_field( wp_unslash( $_POST['view'] ) ) : '';
		$allowed_views = [ 'week', 'day', 'month', 'list' ];

		if ( ! in_array( $view, $allowed_views, true ) ) {
			wp_send_json_error( [ 'message' => 'Invalid view' ] );
		}

		update_user_meta( get_current_user_id(), 'calendar_view', $view );
		wp_send_json_success();
	}

}

$GLOBALS['wc_appointments_admin_ajax'] = new WC_Appointments_Admin_Ajax();
