<?php
/**
 * REST API v2 for slots.
 *
 * Mirrors the existing slots controller but registers under the v2
 * namespace. The underlying product slot calculations already use the
 * indexed path when the feature is enabled, so no further logic change
 * is needed—this controller focuses on namespacing and documentation.
 *
 * @package WooCommerce\Appointments\Rest\Controller\V2
 */

/**
 * V2 Slots controller.
 *
 * Endpoint: `GET /{namespace}/slots` (defaults to v2 namespace)
 *
 * Purpose
 * - Returns available appointment slots with indexed fast-path when enabled.
 * - Adds numeric timestamp support and more flexible filtering compared to v1.
 *
 * Differences vs v1
 * - Uses `WC_Appointments_Controller` cached/indexed implementations when indexing is enabled and the requested range is within the cache horizon (default 3 months via admin setting). Falls back to non-indexed product methods outside horizon or when indexing is disabled.
 * - Includes `timestamp` (UNIX epoch seconds) in each record.
 * - Supports `min_timestamp` and `max_timestamp` filters overriding `min_date`/`max_date`.
 * - Skips transient writes when `WC_APPOINTMENTS_DEBUG` is `true` to reduce I/O in development.
 *
 * Query Parameters (v2)
 * - `product_ids` / `product_id`: As in v1.
 * - `category_ids` / `category_id`: As in v1.
 * - `staff_ids` / `staff_id`: As in v1.
 * - `min_date` / `max_date`: As in v1, parsed via `strtotime`.
 * - `min_timestamp` / `max_timestamp`: Integers (epoch seconds); override `min_date`/`max_date` when provided.
 * - `get_past_times`: `'true'` or `'false'` (default `'false'`).
 * - `page` / `per_page` / `limit`: Pagination controls; applies only if `per_page` or `limit` is provided.
 * - `paged_stream`: `'true'` to enable page-aware early stop generation. Works with `combine_staff=true`.
 * - `hide_unavailable`: `'true'` or `'false'`; when `'true'`, sold-out slots are excluded upstream where possible.
 * - `combine_staff`: `'true'` to return a union across staff (single row per time).
 *
 * Response Schema
 * - `{ records: Array<Slot>, count: number }`
 * - `Slot` fields:
 *   - `product_id`, `product_name`
 *   - `date` (formatted)
 *   - `timestamp` (epoch seconds)
 *   - `duration`, `duration_unit`
 *   - `available`, `scheduled`
 *   - `staff_id`
 *
 * Example
 * - `GET /wc-appointments/v2/slots?product_id=123&min_timestamp=1731283200&max_timestamp=1731369600&hide_unavailable=true`
 */
class WC_Appointments_REST_V2_Slots_Controller extends WC_Appointments_REST_Slots_Controller {

	private function rest_dbg_enabled( $fn = '' ): bool {
		return apply_filters( 'wc_appointments_debug_timing', false, $fn );
	}

	private function rest_dbg_log( string $msg ): void {
		if ( $this->rest_dbg_enabled() ) {
			error_log( '[WC Appointments REST Slots Timing] ' . $msg );
		}
	}

	/**
	 * Constructor allows overriding the namespace for indexed routes.
	 *
	 * @param string $namespace Optional namespace override.
	 */
	public function __construct( $namespace = null ) {
		$this->namespace = $namespace ?: WC_Appointments_REST_API::V2_NAMESPACE;
	}

	/**
	 * Get available appointment slots (v2).
	 *
	 * Route
	 * - `GET /{namespace}/slots` where `{namespace}` defaults to v2.
	 *
	 * Query Parameters
	 * - `product_ids` / `product_id` (optional)
	 * - `category_ids` / `category_id` (optional)
	 * - `staff_ids` / `staff_id` (optional)
	 * - `min_date` / `max_date` (optional): URL-encoded strings parseable by `strtotime`.
	 * - `min_timestamp` / `max_timestamp` (optional): Integers overriding `min_date`/`max_date` when present.
	 * - `get_past_times` (optional): `'true'` or `'false'` (default `'false'`).
     * - `page` / `per_page` / `limit` (optional): Pagination controls; applies only if `per_page` or `limit` is provided.
     * - `paged_stream` (optional): `'true'` to stream by day and stop after `page * per_page` records (only when `combine_staff=true`).
     * - `hide_unavailable` (optional): `'true'` to exclude sold-out slots.
     * - `combine_staff` (optional): `'true'` to union staff into a single combined record per slot.
	 *
	 * Behavior
	 * - Honors product min/max appointable dates by clamping requested range.
	 * - Uses indexed fast-path (`WC_Appointments_Controller::get_cached_*`) when enabled and within cache horizon. Falls back otherwise.
	 * - Skips transient writes and key indexing when `WC_APPOINTMENTS_DEBUG` is true.
	 * - Sorts by date ascending and paginates.
	 *
	 * Response
	 * - `{ records: Array<Slot>, count: number }`; includes `timestamp` in each record.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_items( $request ): WP_REST_Response|WP_Error {
		$__t0 = microtime( true );
		// Accept both plural and singular query params for compatibility.
		// product_ids / product_id
		$product_ids_raw = '';
		if ( ! empty( $request['product_ids'] ) ) {
			$product_ids_raw = $request['product_ids'];
		} elseif ( ! empty( $request['product_id'] ) ) {
			$product_ids_raw = $request['product_id'];
		}
		$product_ids    = $product_ids_raw ? array_map( 'absint', explode( ',', $product_ids_raw ) ) : [];

		// category_ids / category_id
		$category_ids_raw = '';
		if ( ! empty( $request['category_ids'] ) ) {
			$category_ids_raw = $request['category_ids'];
		} elseif ( ! empty( $request['category_id'] ) ) {
			$category_ids_raw = $request['category_id'];
		}
		$category_ids   = $category_ids_raw ? array_map( 'absint', explode( ',', $category_ids_raw ) ) : [];

		// staff_ids / staff_id
		$staff_ids_raw = '';
		if ( ! empty( $request['staff_ids'] ) ) {
			$staff_ids_raw = $request['staff_ids'];
		} elseif ( ! empty( $request['staff_id'] ) ) {
			$staff_ids_raw = $request['staff_id'];
		}
		$staff_ids      = $staff_ids_raw ? array_map( 'absint', explode( ',', $staff_ids_raw ) ) : [];
		$get_past_times = isset( $request['get_past_times'] ) && 'true' === $request['get_past_times'];

		$min_date_raw = urldecode( $request['min_date'] ?? '' );
		$max_date_raw = urldecode( $request['max_date'] ?? '' );
		$min_date = $min_date_raw ? strtotime( $min_date_raw ) : 0;
		$max_date = $max_date_raw ? strtotime( $max_date_raw ) : 0;

		// Allow numeric timestamp filters to override date strings when provided.
		$min_timestamp = absint( $request['min_timestamp'] ?? 0 );
		$max_timestamp = absint( $request['max_timestamp'] ?? 0 );
		if ( $min_timestamp ) {
			$min_date = $min_timestamp;
		}
		if ( $max_timestamp ) {
			$max_date = $max_timestamp;
		} elseif ( $max_date > 0 && $max_date_raw ) {
			// If max_date is a date-only string (YYYY-MM-DD format without time),
			// adjust to end of day to include all slots on that day.
			// This handles cases like max_date=2026-01-17 where we want slots up to 23:59:59 of that day.
			$trimmed_max = trim( $max_date_raw );
			if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $trimmed_max ) ) {
				// Add 1 day to include the entire requested day
				$max_date = strtotime( '+1 day', $max_date );
			}
		}
		$timezone = new \DateTimeZone( wc_timezone_string() );

		$per_page_param   = absint( $request['per_page'] ?? 0 );
		$limit_param      = absint( $request['limit'] ?? 0 );
		$per_page         = 0 < $per_page_param ? $per_page_param : ( 0 < $limit_param ? $limit_param : 0 );
		$page             = max( 1, absint( $request['page'] ?? 1 ) );
		$do_paginate      = 0 < $per_page;
		$hide_unavailable = isset( $request['hide_unavailable'] ) && 'true' === $request['hide_unavailable'];
		$combine_staff    = isset( $request['combine_staff'] ) && 'true' === $request['combine_staff'];
		$paged_stream     = isset( $request['paged_stream'] ) && 'true' === $request['paged_stream'];

		// If no product ids are specified, just use all appointable products.
		if ( [] === $product_ids ) {
			$__tPid = microtime( true );
			$product_ids = \WC_Data_Store::load( 'product-appointment' )->get_appointable_product_ids();
			$this->rest_dbg_log(__FUNCTION__ . ' appointable_products ids=' . count( (array) $product_ids ) . ' took=' . number_format( ( microtime( true ) - $__tPid ) * 1000, 2 ) . 'ms');
		}

		$__tProducts = microtime( true );
		$products = array_filter(
		    array_map(
		        function( $product_id ) {
					// Ensure only readable (published/visible) products are exposed on a public endpoint.
					// This mirrors WooCommerce Store API behavior for public reads.
					if ( ! wc_rest_check_post_permissions( 'product', 'read', $product_id ) ) {
						return null;
					}
					return wc_get_product( $product_id );
				},
		        $product_ids,
		    ),
		);
		$this->rest_dbg_log(__FUNCTION__ . ' products fetched=' . count( $products ) . ' took=' . number_format( ( microtime( true ) - $__tProducts ) * 1000, 2 ) . 'ms');

		foreach ( $products as $product ) {
			$is_vaild_rest_type = 'appointment' === $product->get_type();
			$is_vaild_rest_type = apply_filters( 'woocommerce_appointments_product_type_rest_check', $is_vaild_rest_type, $product );
			if ( ! $is_vaild_rest_type ) {
				wp_send_json( 'Not an appointable product', 400 );
			}
		}

		// If category ids are specified, filter products efficiently.
		// Optimization: has_term accepts an array of IDs directly, avoiding per-term lookups.
		if ( [] !== $category_ids ) {
			$__tCat = microtime( true );
			$products = array_filter(
			    $products,
			    fn($product) => has_term( $category_ids, 'product_cat', $product->get_id() ),
			);
			$this->rest_dbg_log(__FUNCTION__ . ' filter_by_category products=' . count( $products ) . ' took=' . number_format( ( microtime( true ) - $__tCat ) * 1000, 2 ) . 'ms');
		}

		// Get product ids after they are filtered by categories.
		// Optimization: build IDs with foreach to avoid array_map overhead.
		$product_ids = [];
		foreach ( $products as $product ) {
			$product_ids[] = $product->get_id();
		}
		$product_ids = array_filter( $product_ids );

        $transient_name                   = 'appointment_slots_' . md5( http_build_query( [
            'pids'           => $product_ids,
            'cids'           => $category_ids,
            'sids'           => $staff_ids,
            'min'            => $min_date,
            'max'            => $max_date,
            'combine_staff'  => $combine_staff ? 1 : 0,
            'paged_stream'   => ( $do_paginate && $combine_staff && $paged_stream ) ? 1 : 0,
            // Only include pagination keys when pagination is requested
            'page'           => $do_paginate ? (int) $page : 0,
            'per_page'       => $do_paginate ? (int) $per_page : 0,
        ] ) );
		// Skip transient key reads/writes entirely in debug mode to avoid I/O.
		$appointment_slots_transient_keys = [];
		if ( ! \WC_Appointments_Cache::is_debug_mode() ) {
			$appointment_slots_transient_keys = array_filter( (array) \WC_Appointments_Cache::get( 'appointment_slots_transient_keys' ) );
		}
		$cached_availabilities            = \WC_Appointments_Cache::get( $transient_name );

		if ( $cached_availabilities ) {
			if ( $do_paginate ) {
				$availability = wc_appointments_paginated_availability( $cached_availabilities, $page, $per_page );
			} else {
				$availability = [ 'records' => $cached_availabilities, 'count' => count( (array) $cached_availabilities ) ];
			}
			return new WP_REST_Response( $this->transient_expand( $availability ) );
		}

		$needs_cache_set = false;
		if ( ! \WC_Appointments_Cache::is_debug_mode() ) {
			foreach ( $product_ids as $product_id ) {
				if ( ! isset( $appointment_slots_transient_keys[ $product_id ] ) ) {
					$appointment_slots_transient_keys[ $product_id ] = [];
				}

				// Don't store in cache if it already exists there.
				if ( ! in_array( $transient_name, $appointment_slots_transient_keys[ $product_id ] ) ) {
					$appointment_slots_transient_keys[ $product_id ][] = $transient_name;
					$needs_cache_set                                   = true;
				}
			}
		}

		// Only set cache keys if changed (and not in debug mode).
		if ( $needs_cache_set && ! \WC_Appointments_Cache::is_debug_mode() ) {
			\WC_Appointments_Cache::set( 'appointment_slots_transient_keys', $appointment_slots_transient_keys, YEAR_IN_SECONDS );
		}

		// Calculate availability data using indexed fast-path where possible.
		// Optimization: avoid array_map closures; use foreach to reduce overhead.
		$now = current_time( 'timestamp' );
		$scheduled_data = [];
		$__totalSlots = 0;
		$__totalAvail = 0;
		$__totalRecords = 0;
		$did_streaming = false;

		// Page-aware streaming: only when requested and safe (combine staff path)
		if ( $do_paginate && $combine_staff && $paged_stream ) {
			$did_streaming = true;
			$target_count = (int) $per_page * (int) $page;
			$cached_availabilities = [];

			// Prepare product windows and reusable metadata
			$prepared = [];
			$global_min = 0;
			$global_max = 0;
			foreach ( $products as $appointable_product ) {
				$min = $min_date ?: strtotime( 'today' );
				$product_min_date     = $appointable_product->get_min_date_a();
				$min_appointable_date = strtotime( "+{$product_min_date['value']} {$product_min_date['unit']}", $now );
				if ( $min < $min_appointable_date ) { $min = $min_appointable_date; }
				$max = $max_date ?: strtotime( 'tomorrow' );
				$product_max_date     = $appointable_product->get_max_date_a();
				$max_appointable_date = strtotime( "+{$product_max_date['value']} {$product_max_date['unit']}", $now );
				if ( $max <= $min ) { $max = strtotime( '+1 day', $min ); }
				if ( $max > $max_appointable_date ) { $max = $max_appointable_date; }

				$product_staff      = $appointable_product->get_staff_ids() ?: [];
				$duration           = $appointable_product->get_duration();
				$duration_unit      = $appointable_product->get_duration_unit();
				$catalog_visibility = $appointable_product->get_catalog_visibility();
				$staff = empty( $product_staff ) ? [ 0 ] : $product_staff;
				if ( [] !== $staff_ids ) { $staff = array_values( array_intersect( $staff, $staff_ids ) ); }
				$selected_staff = $staff;
				if ( empty( $selected_staff ) && ! empty( $product_staff ) ) { $selected_staff = $product_staff; }
				$prepared[] = [
					'product'      => $appointable_product,
					'prod_min'     => $min,
					'prod_max'     => $max,
					'prod_staff'   => $product_staff,
					'catalog_vis'  => $catalog_visibility,
					'duration'     => $duration,
					'duration_unit'=> $duration_unit,
					'staff_list'   => $selected_staff,
				];
				$global_min = ( 0 === $global_min ) ? $min : min( $global_min, $min );
				$global_max = ( 0 === $global_max ) ? $max : max( $global_max, $max );
			}

			for ( $day = strtotime( 'midnight', $global_min ); $day < $global_max; $day = strtotime( '+1 day', $day ) ) {
				$day_end = strtotime( '+1 day', $day );
				$day_records = [];
				$__tDay = microtime( true );
				foreach ( $prepared as $info ) {
					if ($day < $info['prod_min']) {
                        continue;
                    }
                    if ($day >= $info['prod_max']) {
                        continue;
                    }
                    $appointable_product = $info['product'];
					$duration      = $info['duration'];
					$duration_unit = $info['duration_unit'];
					$catalog_vis   = $info['catalog_vis'];
					$selected_staff= $info['staff_list'];
					$staff_arg     = empty( $selected_staff ) ? 0 : $selected_staff;
					$__tProd = microtime( true );
					$start_date = strtotime( '-1 day', $day );
					$end_date   = strtotime( '+1 day', $day_end );
					$slots = \WC_Appointments_Controller::get_cached_slots_in_range( $appointable_product, $start_date, $end_date, [], $staff_arg, [], $get_past_times, true );
					$__totalSlots += is_array( $slots ) ? count( $slots ) : 0;
					$available_slots = \WC_Appointments_Controller::get_cached_time_slots(
					    $appointable_product,
					    [
							'slots'            => $slots,
							'staff_id'         => $staff_arg,
							'from'             => $start_date,
							'to'               => $end_date,
							'include_sold_out' => ! $hide_unavailable,
						],
					);
					$__totalAvail += is_array( $available_slots ) ? count( $available_slots ) : 0;
					foreach ( $available_slots as $timestamp => $data ) {
						if ($timestamp < $min_date) {
                            continue;
                        }
                        if ($timestamp >= $max_date) {
                            continue;
                        }
                        if ($timestamp < $day) {
                            continue;
                        }
                        if ($timestamp >= $day_end) {
                            continue;
                        }
                        $sum_available = 0;
						if ( isset( $data['staff'] ) && is_array( $data['staff'] ) ) {
							$consider_staff = empty( $selected_staff ) ? array_keys( $data['staff'] ) : $selected_staff;
							foreach ( $consider_staff as $sid ) {
								$sid = (int) $sid; if ( 0 >= $sid ) { continue; }
								$sum_available += (int) ( $data['staff'][ $sid ] ?? 0 );
							}
						} else {
							$sum_available = (int) ( $data['available'] ?? 0 );
						}
						if ( ( ! $hide_unavailable || 1 <= $sum_available ) && 'hidden' !== $catalog_vis ) {
							if ( 'day' === $duration_unit ) {
								$day_timestamp = $timestamp;
								for ( $i = 1; $i < $duration; $i++ ) {
									$day_timestamp = strtotime( '+1 day', $day_timestamp );
									if ( ! isset( $available_slots[ $day_timestamp ] ) ) { continue 2; }
								}
							}
							$day_records[] = [
								self::DATE          => $this->get_time( $timestamp, $timezone ),
								self::TIMESTAMP     => $timestamp,
								self::DURATION      => $duration,
								self::DURATION_UNIT => $duration_unit,
								self::AVAILABLE     => $sum_available,
								self::SCHEDULED     => (int) ( $data['scheduled'] ?? 0 ),
								self::STAFF         => 0,
								self::ID            => $appointable_product->get_id(),
								self::NAME          => $appointable_product->get_title(),
							];
						}
					}
					$this->rest_dbg_log(__FUNCTION__ . ' stream_day=' . gmdate( 'Y-m-d', $day ) . ' day_records=' . count( $day_records ) . ' took=' . number_format( ( microtime( true ) - $__tDay ) * 1000, 2 ) . 'ms');
				}
				if ( [] !== $day_records ) {
					usort( $day_records, fn(array $a, array $b): int => $a[ self::DATE ] <=> $b[ self::DATE ] );
					foreach ( $day_records as $rec ) { $cached_availabilities[] = $rec; }
					$__totalRecords = count( $cached_availabilities );
					if ( $target_count && $__totalRecords >= $target_count ) { break; }
				}
			}

			// Cache and paginate streamed results
			if ( ! \WC_Appointments_Cache::is_debug_mode() ) {
				\WC_Appointments_Cache::set( $transient_name, $cached_availabilities, HOUR_IN_SECONDS );
			}
			$__tPaginate = microtime( true );
			if ( $do_paginate ) {
				$availability = wc_appointments_paginated_availability( $cached_availabilities, $page, $per_page );
			} else {
				$availability = [ 'records' => $cached_availabilities, 'count' => count( $cached_availabilities ) ];
			}
			$this->rest_dbg_log(__FUNCTION__ . ' total_records=' . $__totalRecords . ' total_slots=' . $__totalSlots . ' total_avail=' . $__totalAvail . ' combine=' . ( $combine_staff ? 1 : 0 ) . ' pagination=' . ( $do_paginate ? 1 : 0 ) . ' stream=1 page=' . (int) $page . ' per_page=' . (int) $per_page . ' page_count=' . ( isset( $availability['records'] ) ? count( (array) $availability['records'] ) : 0 ) . ' took=' . number_format( ( microtime( true ) - $__tPaginate ) * 1000, 2 ) . 'ms');
			$this->rest_dbg_log(__FUNCTION__ . ' done took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms');
			return new WP_REST_Response( $this->transient_expand( $availability ) );
		}

		// Non-streaming path (default): compute full range and then paginate
		foreach ( $products as $appointable_product ) {
            $__tProd = microtime( true );
			// Determine a min date (default: today).
			$min = $min_date ?: strtotime( 'today' );

			// Enforce product min appointment date.
			$product_min_date     = $appointable_product->get_min_date_a();
			$min_appointable_date = strtotime( "+{$product_min_date['value']} {$product_min_date['unit']}", $now );
			if ( $min < $min_appointable_date ) {
				$min = $min_appointable_date;
			}

			// Determine a max date (default: tomorrow).
			$max = $max_date ?: strtotime( 'tomorrow' );

			// Enforce product max appointment date.
			$product_max_date     = $appointable_product->get_max_date_a();
			$max_appointable_date = strtotime( "+{$product_max_date['value']} {$product_max_date['unit']}", $now );
			if ( $max <= $min ) {
				$max = strtotime( '+1 day', $min );
			}
			if ( $max > $max_appointable_date ) {
				$max = $max_appointable_date;
			}

            $product_staff      = $appointable_product->get_staff_ids() ?: [];
            $catalog_visibility = $appointable_product->get_catalog_visibility();
            $duration           = $appointable_product->get_duration();
            $duration_unit      = $appointable_product->get_duration_unit();
            $availability       = [];

            $staff = empty( $product_staff ) ? [ 0 ] : $product_staff;
            if ( [] !== $staff_ids ) {
                $staff = array_values( array_intersect( $staff, $staff_ids ) );
            }

			// Get slots using cached/indexed implementation when enabled.
			$start_date = strtotime( '-1 day', $min );
			$end_date   = strtotime( '+1 day', $max );

		$__staffCount = is_array( $staff ) ? count( $staff ) : 0;
		$__prodSlots = 0;
		$__prodAvail = 0;

		if ( $combine_staff ) {
			// Union-of-staff: compute once per product using selected staff subset (or all product staff when none selected)
			$selected_staff = $staff;
			if ( empty( $selected_staff ) && ! empty( $product_staff ) ) {
				$selected_staff = $product_staff;
			}
			$staff_arg = empty( $selected_staff ) ? 0 : $selected_staff;

			$slots = \WC_Appointments_Controller::get_cached_slots_in_range( $appointable_product, $start_date, $end_date, [], $staff_arg, [], $get_past_times, true );
			$__prodSlots += is_array( $slots ) ? count( $slots ) : 0;

			$available_slots = \WC_Appointments_Controller::get_cached_time_slots(
			    $appointable_product,
			    [
					'slots'            => $slots,
					'staff_id'         => $staff_arg,
					'from'             => $start_date,
					'to'               => $end_date,
					'include_sold_out' => ! $hide_unavailable,
				],
			);
			$__prodAvail += is_array( $available_slots ) ? count( $available_slots ) : 0;

			foreach ( $available_slots as $timestamp => $data ) {
				if ($timestamp < $min) {
                    continue;
                }
                if ($timestamp >= $max) {
                    continue;
                }
                // Sum remaining capacities across selected staff only
				$sum_available = 0;
				if ( isset( $data['staff'] ) && is_array( $data['staff'] ) ) {
					$consider_staff = empty( $selected_staff ) ? array_keys( $data['staff'] ) : $selected_staff;
					foreach ( $consider_staff as $sid ) {
					$sid = (int) $sid;
					if ( 0 >= $sid ) { continue; }
					$sum_available += (int) ( $data['staff'][ $sid ] ?? 0 );
				}
			} else {
				$sum_available = (int) ( $data['available'] ?? 0 );
			}

				if ( ( ! $hide_unavailable || 1 <= $sum_available ) && 'hidden' !== $catalog_visibility ) {
					if ( 'day' === $duration_unit ) {
						$day_timestamp = $timestamp;
						for ( $i = 1; $i < $duration; $i++ ) {
							$day_timestamp = strtotime( '+1 day', $day_timestamp );
							if ( ! isset( $available_slots[ $day_timestamp ] ) ) {
								continue 2;
							}
						}
					}

					$availability[] = [
						self::DATE          => $this->get_time( $timestamp, $timezone ),
						self::TIMESTAMP     => $timestamp,
						self::DURATION      => $duration,
						self::DURATION_UNIT => $duration_unit,
						self::AVAILABLE     => $sum_available,
						self::SCHEDULED     => isset( $data['scheduled'] ) ? (int) $data['scheduled'] : 0,
						self::STAFF         => 0,
					];
				}
			}
		} else {
                foreach ( $staff as $staff_id ) {
                    $slots = \WC_Appointments_Controller::get_cached_slots_in_range( $appointable_product, $start_date, $end_date, [], $staff_id, [], $get_past_times, true );
                    $__prodSlots += is_array( $slots ) ? count( $slots ) : 0;

                    $available_slots = \WC_Appointments_Controller::get_cached_time_slots(
                        $appointable_product,
                        [
                            'slots'            => $slots,
                            'staff_id'         => $staff_id,
                            'from'             => $start_date,
                            'to'               => $end_date,
                            'include_sold_out' => ! $hide_unavailable,
                        ],
                    );
                    $__prodAvail += is_array( $available_slots ) ? count( $available_slots ) : 0;

                    foreach ( $available_slots as $timestamp => $data ) {
                        if ($timestamp < $min) {
                            continue;
                        }
                        if ($timestamp >= $max) {
                            continue;
                        }
                        unset( $data['staff'] );

                        if ( ( ! $hide_unavailable || 1 <= $data['available'] ) && 'hidden' !== $catalog_visibility ) {
                            if ( 'day' === $duration_unit ) {
                                $day_timestamp = $timestamp;
                                for ( $i = 1; $i < $duration; $i++ ) {
                                    $day_timestamp = strtotime( '+1 day', $day_timestamp );
                                    if ( ! isset( $available_slots[ $day_timestamp ] ) ) {
                                        continue 2;
                                    }
                                }
                            }

                            $availability[] = [
                                self::DATE          => $this->get_time( $timestamp, $timezone ),
                                self::TIMESTAMP     => $timestamp,
                                self::DURATION      => $duration,
                                self::DURATION_UNIT => $duration_unit,
                                self::AVAILABLE     => $data['available'],
                                self::SCHEDULED     => $data['scheduled'],
                                self::STAFF         => $staff_id,
                            ];
                        }
                    }
                }
            }

			$scheduled_data[] = [
				'product_id'    => $appointable_product->get_id(),
				'product_title' => $appointable_product->get_title(),
				'availability'  => $availability,
			];
			$__totalSlots += $__prodSlots;
			$__totalAvail += $__prodAvail;
			$__totalRecords += is_array( $availability ) ? count( $availability ) : 0;
			$this->rest_dbg_log(__FUNCTION__ . ' product=' . (int) $appointable_product->get_id() . ' staff=' . $__staffCount . ' slots=' . $__prodSlots . ' avail=' . $__prodAvail . ' out=' . ( is_array( $availability ) ? count( $availability ) : 0 ) . ' took=' . number_format( ( microtime( true ) - $__tProd ) * 1000, 2 ) . 'ms');
		}

		$scheduled_data = apply_filters( 'woocommerce_appointments_rest_slots_get_items', $scheduled_data );

		// Optimization: flatten availability without array_map/variadic merge to reduce memory churn.
		$__tFlatten = microtime( true );
		$cached_availabilities = [];
		foreach ( $scheduled_data as $value ) {
			$pid   = $value['product_id'];
			$pname = $value['product_title'];
			foreach ( $value['availability'] as $availability ) {
				$availability[ self::ID ]   = $pid;
				$availability[ self::NAME ] = $pname;
				$cached_availabilities[]    = $availability;
			}
		}
		$this->rest_dbg_log(__FUNCTION__ . ' flattened=' . count( $cached_availabilities ) . ' took=' . number_format( ( microtime( true ) - $__tFlatten ) * 1000, 2 ) . 'ms');

		// Sort by date.
		usort(
		    $cached_availabilities,
		    fn(array $a, array $b): int => $a[ self::DATE ] <=> $b[ self::DATE ],
		);

		// Cache for an hour for repeated client calls (skip writes in debug mode).
		if ( ! \WC_Appointments_Cache::is_debug_mode() ) {
			\WC_Appointments_Cache::set( $transient_name, $cached_availabilities, HOUR_IN_SECONDS );
		}

        $__tPaginate = microtime( true );
        if ( $do_paginate ) {
            $availability = wc_appointments_paginated_availability( $cached_availabilities, $page, $per_page );
        } else {
            $availability = [ 'records' => $cached_availabilities, 'count' => count( $cached_availabilities ) ];
        }
        $this->rest_dbg_log(__FUNCTION__ . ' total_records=' . $__totalRecords . ' total_slots=' . $__totalSlots . ' total_avail=' . $__totalAvail . ' combine=' . ( $combine_staff ? 1 : 0 ) . ' pagination=' . ( $do_paginate ? 1 : 0 ) . ' page=' . (int) $page . ' per_page=' . (int) $per_page . ' page_count=' . ( isset( $availability['records'] ) ? count( (array) $availability['records'] ) : 0 ) . ' took=' . number_format( ( microtime( true ) - $__tPaginate ) * 1000, 2 ) . 'ms');
        $this->rest_dbg_log(__FUNCTION__ . ' done took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms');

        return new WP_REST_Response( $this->transient_expand( $availability ) );
	}
}