<?php
/**
 * REST API Product slots objects.
 *
 * Handles requests to the /slots endpoint.
 *
 * @package WooCommerce\Appointments\Rest\Controller
 */

/**
 * REST API Products controller class.
 *
 * Endpoint: `GET /{namespace}/slots`
 *
 * Purpose
 * - Returns available appointment slots for one or more appointable products.
 * - Filters by product, category, staff, date/time range, and visibility flags.
 * - Sorts ascending by date and supports pagination.
 *
 * Namespace
 * - Defaults to v1 (`WC_Appointments_REST_API::V1_NAMESPACE`).
 * - The v2 controller overrides the namespace and adds additional capabilities.
 *
 * Query Parameters (v1)
 * - `product_ids` (string): Comma-separated product IDs. Example: `"12,34,56"`. When omitted, all appointable products are used.
 * - `product_id` (int): Single product ID. Alternative to `product_ids`.
 * - `category_ids` (string): Comma-separated WooCommerce product category IDs to filter products. Example: `"4,9"`.
 * - `category_id` (int): Single category ID. Alternative to `category_ids`.
 * - `staff_ids` (string): Comma-separated staff IDs to filter slot results. Example: `"2,3"`.
 * - `staff_id` (int): Single staff ID. Alternative to `staff_ids`.
 * - `min_date` (string): URL-encoded date/time interpreted by `strtotime`. Recommended formats: `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`.
 * - `max_date` (string): URL-encoded date/time interpreted by `strtotime`. Recommended formats: `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`.
 * - `get_past_times` (string): `'true'` to include past slots, `'false'` to exclude. Default `'false'`.
 * - `page` (int): 1-based page index. When omitted, results are not paginated.
 * - `limit` (int): Page size when `page` is set. Default `10`.
 * - `hide_unavailable` (string): `'true'` to hide slots where `available <= 0`, `'false'` to include. Default `'false'`.
 *
 * Behavior
 * - Enforces each product's minimum and maximum appointable dates and adjusts requested range accordingly.
 * - Uses the original product slot calculations (`get_slots_in_range` / `get_time_slots`).
 * - Results are sorted by date ascending and then paginated.
 *
 * Response Schema
 * - `{ records: Array<Slot>, count: number }`
 * - `Slot` fields:
 *   - `product_id` (int)
 *   - `product_name` (string)
 *   - `date` (string, site timezone, `YYYY-MM-DDTHH:mm`)
 *   - `duration` (int)
 *   - `duration_unit` (string, e.g. `'minute'`, `'hour'`, `'day'`, `'month'`)
 *   - `available` (int)
 *   - `scheduled` (int)
 *   - `staff_id` (int)
 *
 * Examples
 * - All appointable products for a single day: `GET /wc-appointments/v1/slots?min_date=2025-11-12&max_date=2025-11-13`
 * - Single product, hide unavailable: `GET /wc-appointments/v1/slots?product_id=123&min_date=2025-11-12&max_date=2025-11-13&hide_unavailable=true`
 */
class WC_Appointments_REST_Slots_Controller extends WC_REST_Controller {

	use WC_Appointments_Rest_Permission_Check;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = WC_Appointments_REST_API::V1_NAMESPACE;

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'slots';

	/**
	 * Register the route for appointments slots.
	 */
	public function register_routes(): void {
		register_rest_route(
		    $this->namespace,
		    '/' . $this->rest_base,
		    [
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'get_items' ],
					'permission_callback' => '__return_true',
				],
			],
		);
	}

	/**
	 * Abbreviations constants.
	 */
	public const AVAILABLE     = 'a';
	public const SCHEDULED     = 'b';
	public const DATE          = 'd';
	// New abbreviated key to carry raw unix timestamp alongside human-readable date.
	public const TIMESTAMP     = 'ts';
	public const DURATION      = 'du';
	public const DURATION_UNIT = 'duu';
	public const ID            = 'i';
	public const NAME          = 'n';
	public const STAFF         = 's';

	/**
	 * Mapping of abbrieviations to expanded versions of lables.
	 * Used to minimize storred transient size.
	 */
	protected array $transient_keys_mapping = [
		self::AVAILABLE     => 'available',
		self::SCHEDULED     => 'scheduled',
		self::DATE          => 'date',
		// Expanded label for timestamp for client-side numeric consumption.
		self::TIMESTAMP     => 'timestamp',
		self::DURATION      => 'duration',
		self::DURATION_UNIT => 'duration_unit',
		self::STAFF         => 'staff_id',
		self::ID            => 'product_id',
		self::NAME          => 'product_name',
	];

	/**
	 * @param $availablity with abbreviated lables.
	 *
	 * @return object with lables expanded to their full version.
	 */
	public function transient_expand( $availability ): array {
		$expanded_availability = [];
		foreach ( $availability['records'] as $slot ) {
			$expanded_slot = [];
			foreach ( $slot as $abbrieviation  => $value ) {
				$expanded_slot[ $this->transient_keys_mapping[ $abbrieviation ] ] = $value;
			}
			$expanded_availability[] = $expanded_slot;
		}

		return [
			'records' => $expanded_availability,
			'count'   => $availability['count'],
		];
	}

	/**
	 * Format timestamp for API.
	 *
	 * Format a timestamp to the shortest reasonable format usable in the API response,
	 * respecting the provided timezone.
	 *
	 * @param int          $timestamp Timestamp to format.
	 * @param DateTimeZone $timezone  Timezone to use for formatting.
	 *
	 * @return string Formatted date string (ISO 8601 subset).
	 */
	public function get_time( $timestamp, $timezone ): string {
		$server_time = new DateTime( date( 'Y-m-d\TH:i:s', $timestamp ), $timezone );

		return $server_time->format( "Y-m-d\TH:i" );
	}

	/**
	 * Get available appointments slots (v1).
	 *
	 * Route
	 * - `GET /{namespace}/slots` where `{namespace}` defaults to v1.
	 *
	 * Query Parameters
	 * - `product_ids` (string, optional): Comma-separated product IDs.
	 * - `product_id` (int, optional): Single product ID; alternative to `product_ids`.
	 * - `category_ids` (string, optional): Comma-separated category IDs.
	 * - `category_id` (int, optional): Single category ID; alternative to `category_ids`.
	 * - `staff_ids` (string, optional): Comma-separated staff IDs.
	 * - `staff_id` (int, optional): Single staff ID; alternative to `staff_ids`.
	 * - `min_date` (string, optional): URL-encoded date/time string parseable by `strtotime`.
	 * - `max_date` (string, optional): URL-encoded date/time string parseable by `strtotime`.
	 * - `get_past_times` (string, optional): `'true'` or `'false'`. Defaults to `'false'`.
	 * - `page` (int, optional): 1-based index for pagination. Omit for unpaginated.
	 * - `limit` (int, optional): Page size when `page` is present. Defaults to `10`.
	 * - `hide_unavailable` (string, optional): `'true'` to hide sold-out slots; defaults to `'false'`.
	 *
	 * Behavior
	 * - Adjusts requested range to honor each product's min/max appointable dates.
	 * - Uses product methods for slot calculation (non-indexed path).
	 * - Sorts by date ascending and applies pagination.
	 *
	 * Response
	 * - `{ records: Array<Slot>, count: number }`, see class docblock for field descriptions.
	 *
	 * @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 {
		$product_ids    = empty( $request['product_ids'] ) ? [] : array_map( 'absint', explode( ',', $request['product_ids'] ) );
		$category_ids   = empty( $request['category_ids'] ) ? [] : array_map( 'absint', explode( ',', $request['category_ids'] ) );
		$staff_ids      = empty( $request['staff_ids'] ) ? [] : array_map( 'absint', explode( ',', $request['staff_ids'] ) );
		$get_past_times = isset( $request['get_past_times'] ) && 'true' === $request['get_past_times'];

		$min_date = strtotime( urldecode( $request['min_date'] ?? '' ) ) ?? 0;
		$max_date = strtotime( urldecode( $request['max_date'] ?? '' ) ) ?? 0;
		$timezone = new DateTimeZone( wc_timezone_string() );

		$page             = $request['page'] ?? false;
		$records_per_page = absint( $request['limit'] ?? 10 );
		$hide_unavailable = isset( $request['hide_unavailable'] ) && 'true' === $request['hide_unavailable'];

		// If no product ids are specified, just use all products.
		if ( [] === $product_ids ) {
			$product_ids = WC_Data_Store::load( 'product-appointment' )->get_appointable_product_ids();
		}

		$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,
		    ),
		);

		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 the product ids.
		if ( [] !== $category_ids ) {
			$products = array_filter(
			    $products,
			    function( $product ) use ( $category_ids ) {
					$product_id = $product->get_id();

					return array_reduce(
					    $category_ids,
					    function( $is_in_category, $category_id ) use ( $product_id ) {
							$term = get_term_by( 'id', $category_id, 'product_cat' );

							if ( ! $term ) {
								return $is_in_category;
							}

							return $is_in_category || has_term( $term, 'product_cat', $product_id );
						},
					    false,
					);
				},
			);
		}

		// Get product ids from products after they filtered by categories.
		$product_ids = array_filter(
		    array_map(
		        fn($product) => $product->get_id(),
		        $products,
		    ),
		);

		$transient_name                   = 'appointment_slots_' . md5( http_build_query( [ $product_ids, $category_ids, $staff_ids, $min_date, $max_date, $page, $records_per_page ] ) );
		$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 ) {
			$availability = wc_appointments_paginated_availability( $cached_availabilities, $page, $records_per_page );
			return rest_ensure_response( $this->transient_expand( $availability ) );
		}

		$needs_cache_set = false;
		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 if existing cached data has changed.
		if ( $needs_cache_set ) {
			// Give array of keys a long ttl because if it expires we won't be able to flush the keys when needed.
			// We can't use 0 to never expire because then WordPress will autoload the option on every page.
			WC_Appointments_Cache::set( 'appointment_slots_transient_keys', $appointment_slots_transient_keys, YEAR_IN_SECONDS );
		}

		// Calculate partially scheduled/fully scheduled/unavailable days for each product.
		$scheduled_data = array_values(
		    array_map(
		        function( $appointable_product ) use ( $min_date, $max_date, $staff_ids, $get_past_times, $timezone, $hide_unavailable ): array {
					// Determine a min date.
					if ( 0 === $min_date || false === $min_date ) {
						$min_date = strtotime( 'today' );
					}

					// Get Minimum appointable time in the future.
					$product_min_date     = $appointable_product->get_min_date_a();
					$min_appointable_date = strtotime( "+{$product_min_date['value']} {$product_min_date['unit']}", current_time( 'timestamp' ) );

					// Reset $min_date if it's before the minimum appointable date/time in the future.
					if ( $min_date < $min_appointable_date ) {
						$min_date = $min_appointable_date;
					}

					// Determine a max date.
					if ( 0 === $max_date || false === $max_date ) {
						$max_date = strtotime( 'tomorrow' );
					}

					// Get Maximum appointable time in the future.
					$product_max_date     = $appointable_product->get_max_date_a();
					$max_appointable_date = strtotime( "+{$product_max_date['value']} {$product_max_date['unit']}", current_time( 'timestamp' ) );

					// Reset $max_date to the next dat of the $min_date if it is smaller.
					if ( $max_date < $min_date ) {
						$max_date = strtotime( '+1 day', $min_date );
					}

					// Reset $max_date if it's after the maximum appointable date/time in the future.
					if ( $max_date > $max_appointable_date ) {
						$max_date = $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_intersect( $staff, $staff_ids );
					}

					// Get slots for days before and after, which accounts for timezone differences.
					$start_date = strtotime( '-1 day', $min_date );
					$end_date   = strtotime( '+1 day', $max_date );

					foreach ( $staff as $staff_id ) {
						$slots           = $appointable_product->get_slots_in_range( $start_date, $end_date, [], $staff_id, [], $get_past_times );
						$available_slots = $appointable_product->get_time_slots(
						    [
								'slots'            => $slots,
								'staff_id'         => $staff_id,
								'from'             => $start_date,
								'to'               => $end_date,
								'include_sold_out' => true,
							],
						);

						foreach ( $available_slots as $timestamp => $data ) {
							// Filter slots outside of timerange.
                            if ($timestamp < $min_date) {
                                continue;
                            }
                            if ($timestamp >= $max_date) {
                                continue;
                            }
                            unset( $data['staff'] );

							if ( ( ! $hide_unavailable || 1 <= $data['available'] ) && 'hidden' !== $catalog_visibility ) {
								/**
								 * For the day duration, check if the required duration is available in the following days or not
								 * For example, for 3 days duration, check if the starting day has the following 3 days available or not.
								 * If it's available, the starting day should be considered available, otherwise not.
								 */
								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::DURATION      => $duration,
									self::DURATION_UNIT => $duration_unit,
									self::AVAILABLE     => $data['available'],
									self::SCHEDULED     => $data['scheduled'],
									self::STAFF         => $staff_id,
								];

							}
						}
					}

					return [
						'product_id'    => $appointable_product->get_id(),
						'product_title' => $appointable_product->get_title(),
						'availability'  => $availability,
					];
				},
		        $products,
		    ),
		);

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

		$cached_availabilities = array_merge(
		    ...array_map(
		        fn(array $value): array => array_map(
		            function( array $availability ) use ( $value ): array {
							$availability[ self::ID ]   = $value['product_id'];
							$availability[ self::NAME ] = $value['product_title'];
							return $availability;
						},
		            $value['availability'],
		        ),
		        $scheduled_data,
		    ),
		);

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

		// This transient should be cleared when appointment or products are added or updated but keep it short just in case.
		WC_Appointments_Cache::set( $transient_name, $cached_availabilities, HOUR_IN_SECONDS );

		$availability = wc_appointments_paginated_availability( $cached_availabilities, $page, $records_per_page );

		return rest_ensure_response( $this->transient_expand( $availability ) );
	}
}
