<?php
/**
 * REST API v2 for availabilities.
 *
 * Adds full CRUD support for availability rules. Index queries are
 * provided by a dedicated v2 index controller and are no longer
 * routed through this CRUD controller.
 *
 * @package WooCommerce\Appointments\Rest\Controller\V2
 */

/**
 * V2 Availabilities controller.
 *
 * Endpoints
 * - `GET /{namespace}/availabilities`
 * - `GET /{namespace}/availabilities/{id}`
 * - `POST /{namespace}/availabilities`
 * - `PUT|PATCH /{namespace}/availabilities/{id}`
 * - `DELETE /{namespace}/availabilities/{id}`
 *
 * Permissions
 * - Read: Public for list and single.
 * - Mutations: `manage_woocommerce` capability.
 *
 * Query Parameters
 * - `filter` (array): Identical to v1; passed to availability data store.
 *
 * Item Schema
 * - See `get_item_schema()` for documented fields: `id`, `kind`, `kind_id`,
 *   `event_id`, `title`, `range_type`, `from_date`, `to_date`, `from_range`,
 *   `to_range`, `appointable`, `priority`, `qty`, `ordering`, `rrule`.
 *
 * Notes
 * - For occurrence reads at scale, prefer `GET /wc-appointments/v2/index`.
 */
class WC_Appointments_REST_V2_Availabilities_Controller extends WC_REST_Controller {

	use WC_Appointments_Rest_Permission_Check;

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

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

	/**
	 * Register routes for CRUD over availability rules.
	 */
	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',
					'args'                => $this->get_collection_params(),
				],
				[
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => [ $this, 'create_item' ],
					'permission_callback' => [ $this, 'create_item_permissions_check' ],
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
				],
				'schema' => [ $this, 'get_item_schema' ],
			],
		);

		register_rest_route(
		    $this->namespace,
		    '/' . $this->rest_base . '/(?P<id>[\d]+)',
		    [
				'args'   => [
					'id' => [
						'description' => 'Unique identifier for the resource.',
						'type'        => 'integer',
					],
				],
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'get_item' ],
					'permission_callback' => [ $this, 'get_item_permissions_check' ],
					'args'                => [
						'context' => $this->get_context_param( [ 'default' => 'view' ] ),
					],
				],
				[
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => [ $this, 'update_item' ],
					'permission_callback' => [ $this, 'update_item_permissions_check' ],
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				],
				[
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => [ $this, 'delete_item' ],
					'permission_callback' => [ $this, 'delete_item_permissions_check' ],
					'args'                => [
						'force' => [
							'default'     => false,
							'description' => 'Whether to bypass trash and force deletion.',
							'type'        => 'boolean',
						],
					],
				],
				'schema' => [ $this, 'get_item_schema' ],
			],
		);
	}

	/**
	 * List availability rules.
	 *
	 * Optimized to use indexed cache when available and within horizon.
	 * Falls back to v1 behavior (availability data store) when:
	 * - Indexing is disabled
	 * - Request is outside cache horizon
	 * - No date range is provided
	 *
	 * Accepts a `filter` array identical to v1 for continuity, plus optimized parameters:
	 * - `start_ts` (int): Start timestamp (UTC epoch seconds) for time window filtering
	 * - `end_ts` (int): End timestamp (UTC epoch seconds) for time window filtering
	 * - `product_id` (int): Filter by product ID
	 * - `staff_id` (int): Filter by staff ID
	 * - `scope` (string|array): Filter by scope(s) - 'global', 'product', 'staff', or array of multiple
	 * - `appointable` (bool): Filter by appointable status (true = available, false = unavailable)
	 * - `scopes` (array): Array of scope objects with 'kind' and 'kind_id' for batch fetching
	 *
	 * When using optimized parameters with indexed cache, this can fetch multiple scopes
	 * in a single query, reducing from 12+ API calls to 1-2 calls.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ): WP_REST_Response {
		// Check if we should use indexed cache
		$use_indexed_cache = false;
		$start_ts = 0;
		$end_ts = 0;

		// Check if indexed availability is enabled
		if ( class_exists( 'WC_Appointments_Cache_Availability' ) && method_exists( 'WC_Appointments_Cache_Availability', 'is_index_enabled' ) ) {
			$use_indexed_cache = WC_Appointments_Cache_Availability::is_index_enabled();
		}

		// Get time window if provided (for horizon check and optimized queries)
		if ( $use_indexed_cache ) {
			$start_ts_param = $request->get_param( 'start_ts' );
			$end_ts_param = $request->get_param( 'end_ts' );

			if ( $start_ts_param ) {
				$start_ts = absint( $start_ts_param );
			}
			if ( $end_ts_param ) {
				$end_ts = absint( $end_ts_param );
			}

			// Check cache horizon if we have a time window
			if ( 0 < $start_ts || 0 < $end_ts ) {
				$horizon_months = function_exists( 'wc_appointments_get_cache_horizon_months' ) ? wc_appointments_get_cache_horizon_months() : 3;
				$horizon_ts = strtotime( '+' . $horizon_months . ' months UTC' );

				// Only use indexed cache if within horizon
				if ( 0 < $end_ts && $end_ts > $horizon_ts ) {
					$use_indexed_cache = false;
				}
			} else {
				// No time window provided, can't use indexed cache efficiently
				$use_indexed_cache = false;
			}
		}

		// Use indexed cache if enabled and within horizon
		if ( $use_indexed_cache && class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return $this->get_items_from_indexed_cache( $request, $start_ts, $end_ts );
		}

		// Fall back to v1 behavior: query availability data store directly
		// This preserves backward compatibility and handles cases outside cache horizon
		// If scopes parameter is provided, we need to transform the response to match the batch format
		$scopes = $request->get_param( 'scopes' );
		if ( ! empty( $scopes ) ) {
			// Decode scopes if it's a JSON string
			if ( is_string( $scopes ) ) {
				$scopes = json_decode( $scopes, true );
			}
			if ( ! is_array( $scopes ) ) {
				$scopes = [];
			}

			// Build filters for each scope and fetch rules
			$results = [];
			foreach ( $scopes as $scope ) {
				$kind = $scope['kind'] ?? '';
				$kind_id = $scope['kindId'] ?? $scope['kind_id'] ?? null;

				// Extract scope name from kind (e.g., "availability#global" -> "global")
				$scope_name = '';
				if ( strpos( $kind, '#global' ) !== false ) {
					$scope_name = WC_Appointments_Availability::SCOPE_GLOBAL;
				} elseif ( strpos( $kind, '#product' ) !== false ) {
					$scope_name = WC_Appointments_Availability::SCOPE_PRODUCT;
				} elseif ( strpos( $kind, '#staff' ) !== false ) {
					$scope_name = WC_Appointments_Availability::SCOPE_STAFF;
				}
				if ( '' === $scope_name ) {
					continue;
				}

				// Build filters for this scope
				$filters = [
					[
						'key'     => 'kind',
						'compare' => '=',
						'value'   => $kind,
					],
				];

				// Add kind_id filter if provided
				if ( null !== $kind_id && 0 < absint( $kind_id ) ) {
					$filters[] = [
						'key'     => 'kind_id',
						'compare' => '=',
						'value'   => absint( $kind_id ),
					];
				}

				// Apply additional filters from request (e.g., product_id, staff_id)
				$product_id = $request->get_param( 'product_id' );
				$staff_id = $request->get_param( 'staff_id' );
				if ( WC_Appointments_Availability::SCOPE_PRODUCT === $scope_name && $product_id ) {
					$filters[] = [
						'key'     => 'kind_id',
						'compare' => '=',
						'value'   => absint( $product_id ),
					];
				}
				if ( WC_Appointments_Availability::SCOPE_STAFF === $scope_name && $staff_id ) {
					$filters[] = [
						'key'     => 'kind_id',
						'compare' => '=',
						'value'   => absint( $staff_id ),
					];
				}

				$prepared_args = apply_filters( 'woocommerce_rest_availabilities_query', $filters, $request );
				$items = \WC_Data_Store::load( 'appointments-availability' )->get_all_as_array( $prepared_args );

				// Separate by appointable status
				$unavailable = [];
				$available = [];
				foreach ( $items as $item ) {
					// Ensure appointable is boolean
					$appointable = $item['appointable'] ?? true;
					if ( is_string( $appointable ) ) {
						$appointable = ( 'yes' === $appointable || '1' === $appointable || 'true' === strtolower( $appointable ) );
					}
					$item['appointable'] = (bool) $appointable;

					if ( $appointable ) {
						$available[] = $item;
					} else {
						$unavailable[] = $item;
					}
				}

				// Store results keyed by scope kind for easy lookup
				$results[ $kind ] = [
					'unavailable' => $unavailable,
					'available' => $available,
				];
			}

			return rest_ensure_response( $results );
		}

		// No scopes parameter: return flat array (v1 behavior)
		$prepared_args = [];
		if ( ! empty( $request['filter'] ) ) {
			$prepared_args = (array) $request['filter'];
		}
		$prepared_args = apply_filters( 'woocommerce_rest_availabilities_query', $prepared_args, $request );

		$items = \WC_Data_Store::load( 'appointments-availability' )->get_all_as_array( $prepared_args );
		return rest_ensure_response( $items );
	}

	/**
	 * Get availability rules from indexed cache (optimized path).
	 *
	 * This method fetches rules from the indexed cache table, which is much faster
	 * than querying the availability data store. It supports:
	 * - Multiple scopes in a single query
	 * - Batch fetching of unavailable and available rules
	 * - Efficient filtering by product_id, staff_id, and time window
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @param int             $start_ts Start timestamp (UTC epoch seconds).
	 * @param int             $end_ts End timestamp (UTC epoch seconds).
	 * @return WP_REST_Response
	 */
	protected function get_items_from_indexed_cache( $request, $start_ts, $end_ts ): WP_REST_Response {
		$data_store = WC_Data_Store::load( 'appointments-availability-cache' );
		if ( ! $data_store ) {
			// Fallback if data store not available
			return rest_ensure_response( [] );
		}

		// Check if this is a batch request for multiple scopes (optimized admin calendar use case)
		// The scopes parameter may be a JSON string (from JavaScript) or an array (from PHP)
		$scopes = $request->get_param( 'scopes' );
		if ( ! empty( $scopes ) ) {
			// If scopes is a JSON string, decode it
			if ( is_string( $scopes ) ) {
				$decoded = json_decode( $scopes, true );
				if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
					$scopes = $decoded;
				}
			}
			// If scopes is an array, normalize keys (kindId -> kind_id) and use batch fetching
			if ( is_array( $scopes ) && [] !== $scopes ) {
				// Normalize camelCase keys from JavaScript to snake_case for PHP
				// Also convert kind_id to integer if it's a string
				$normalized_scopes = array_map( function( array $scope ): array {
					if ( isset( $scope['kindId'] ) && ! isset( $scope['kind_id'] ) ) {
						$scope['kind_id'] = $scope['kindId'];
						unset( $scope['kindId'] );
					}
					// Convert kind_id to integer if it's a string (from JSON)
					if ( isset( $scope['kind_id'] ) && is_string( $scope['kind_id'] ) && is_numeric( $scope['kind_id'] ) ) {
						$scope['kind_id'] = (int) $scope['kind_id'];
					}
					// Convert null string to actual null
					if ( isset( $scope['kind_id'] ) && 'null' === $scope['kind_id'] ) {
						$scope['kind_id'] = null;
					}
					return $scope;
				}, $scopes );
				return $this->get_batch_scopes_from_indexed_cache( $request, $data_store, $start_ts, $end_ts, $normalized_scopes );
			}
		}

		// Single scope query (standard case)
		$filters = [
			'source' => 'availability',
		];

		// Add time window filter
		if ( 0 < $start_ts && 0 < $end_ts && $end_ts > $start_ts ) {
			$filters['time_between'] = [
				'start_ts' => $start_ts,
				'end_ts'   => $end_ts,
			];
		}

		// Add product filter if provided
		if ( $request->has_param( 'product_id' ) ) {
			$filters['product_id'] = absint( $request->get_param( 'product_id' ) );
		}

		// Add staff filter if provided
		if ( $request->has_param( 'staff_id' ) ) {
			$filters['staff_id'] = absint( $request->get_param( 'staff_id' ) );
		}

		// Add scope filter if provided
		if ( $request->has_param( 'scope' ) ) {
			$scope_param = $request->get_param( 'scope' );
			if ( is_array( $scope_param ) ) {
				// Multiple scopes - we'll need to handle this differently
				// For now, fetch all and filter client-side, or make multiple queries
				// In practice, batch_scopes is preferred for multiple scopes
			} else {
				$filters['scope'] = sanitize_text_field( $scope_param );
			}
		}

		// Add appointable filter if provided
		if ( $request->has_param( 'appointable' ) ) {
			$filters['appointable'] = wc_string_to_bool( $request->get_param( 'appointable' ) ) ? 'yes' : 'no';
		}

		// Query indexed cache
		$rows = $data_store->get_items( $filters );

		// Convert cache rows to availability rule format for backward compatibility
		$items = $this->convert_cache_rows_to_availability_rules( $rows );

		return rest_ensure_response( $items );
	}

	/**
	 * Get availability rules for multiple scopes in batch (optimized for admin calendar).
	 *
	 * This method efficiently fetches rules for multiple scopes (global, product, staff)
	 * and both appointable states (available/unavailable) in minimal API calls.
	 * Reduces from 12+ calls to 1-2 calls by batching queries.
	 *
	 * Expected scopes format:
	 * [
	 *   { 'kind': 'availability#global', 'kind_id': null },
	 *   { 'kind': 'availability#product', 'kind_id': 123 },
	 *   { 'kind': 'availability#staff', 'kind_id': 456 }
	 * ]
	 *
	 * Returns rules organized by scope and appointable status for easy processing.
	 *
	 * @param WP_REST_Request                                    $request Full details about the request.
	 * @param WC_Appointments_Availability_Cache_Data_Store $data_store Cache data store instance.
	 * @param int                                                 $start_ts Start timestamp (UTC epoch seconds).
	 * @param int                                                 $end_ts End timestamp (UTC epoch seconds).
	 * @param array                                               $scopes Array of scope objects with 'kind' and 'kind_id'.
	 * @return WP_REST_Response
	 */
	protected function get_batch_scopes_from_indexed_cache( $request, $data_store, $start_ts, $end_ts, $scopes ): WP_REST_Response {
		$results = [];

		// Build base filters
		$base_filters = [
			'source' => 'availability',
		];

		// Add time window filter
		if ( 0 < $start_ts && 0 < $end_ts && $end_ts > $start_ts ) {
			$base_filters['time_between'] = [
				'start_ts' => $start_ts,
				'end_ts'   => $end_ts,
			];
		}

		// Fetch all rules for the time window in one query
		// We don't filter by product_id/staff_id here because:
		// - Global rules don't have product_id/staff_id
		// - Product rules have product_id but may not have staff_id
		// - Staff rules have staff_id
		// We'll filter by scope and kind_id in PHP after fetching
		$all_filters = $base_filters;

		// Build scope filter to fetch only relevant scopes (global, product, staff)
		$scope_names = [];
		foreach ( $scopes as $scope ) {
			$kind = $scope['kind'] ?? '';
			if ( strpos( $kind, '#product' ) !== false ) {
				$scope_names[] = WC_Appointments_Availability::SCOPE_PRODUCT;
			} elseif ( strpos( $kind, '#staff' ) !== false ) {
				$scope_names[] = WC_Appointments_Availability::SCOPE_STAFF;
			} else {
				$scope_names[] = WC_Appointments_Availability::SCOPE_GLOBAL;
			}
		}
		$scope_names = array_unique( $scope_names );
		if ( [] !== $scope_names ) {
			$all_filters['scope'] = $scope_names;
		}

		// Fetch all matching rules (without product_id/staff_id filters)
		$all_rows = $data_store->get_items( $all_filters );

		// Performance optimization: Collect all rule IDs first and prime caches
		$all_rule_ids = [];
		$scope_rules_map = []; // Map scope kind to array of [rule_id => cache_row]

		// First pass: collect all rule IDs and organize by scope
		foreach ( $scopes as $scope ) {
			$kind = $scope['kind'] ?? '';
			$kind_id = $scope['kind_id'] ?? null;

			// Determine scope from kind
			$scope_name = WC_Appointments_Availability::SCOPE_GLOBAL;
			if ( strpos( $kind, '#product' ) !== false ) {
				$scope_name = WC_Appointments_Availability::SCOPE_PRODUCT;
			} elseif ( strpos( $kind, '#staff' ) !== false ) {
				$scope_name = WC_Appointments_Availability::SCOPE_STAFF;
			}

			// Filter rows for this scope
			$scope_rows = array_filter( $all_rows, function( array $row ) use ( $scope_name, $kind_id ): bool {
				$row_scope = $row['scope'] ?? '';
				$row_kind_id = $row['source_id'] ?? 0;

				// Match scope
				if ( $row_scope !== $scope_name ) {
					return false;
				}

				// For global scope, ensure product_id and staff_id are both 0
				if ( WC_Appointments_Availability::SCOPE_GLOBAL === $scope_name ) {
					$row_product_id = (int) ( $row['product_id'] ?? 0 );
					$row_staff_id = (int) ( $row['staff_id'] ?? 0 );
					// Global rules must have both product_id and staff_id as 0
					if ( 0 !== $row_product_id || 0 !== $row_staff_id ) {
						return false;
					}
				}

				// Match kind_id if specified
				if ( null !== $kind_id ) {
					// For product scope, check product_id; for staff scope, check staff_id
					if ( WC_Appointments_Availability::SCOPE_PRODUCT === $scope_name ) {
						$row_product_id = $row['product_id'] ?? 0;
						if ( (int) $row_product_id !== (int) $kind_id ) {
							return false;
						}
					} elseif ( WC_Appointments_Availability::SCOPE_STAFF === $scope_name ) {
						$row_staff_id = $row['staff_id'] ?? 0;
						if ( (int) $row_staff_id !== (int) $kind_id ) {
							return false;
						}
					}
				}

				return true;
			} );

			// Group rows by source_id (rule ID) to return unique rules (not individual occurrences)
			// The cache rows represent occurrences, but we want to return the original rule definitions
			$rules_by_id = [];
			foreach ( $scope_rows as $row ) {
				$rule_id = absint( $row['source_id'] ?? 0 );
				if ( 0 < $rule_id && ! isset( $rules_by_id[ $rule_id ] ) ) {
					// Store the first occurrence row for this rule (we'll use it for appointable status)
					$rules_by_id[ $rule_id ] = $row;
					$all_rule_ids[] = $rule_id;
				}
			}

			$scope_rules_map[ $kind ] = $rules_by_id;
		}

		// Performance optimization: Prime post and meta caches for all availability rules
		$all_rule_ids = array_unique( $all_rule_ids );
		if ( [] !== $all_rule_ids ) {
			// Prime post cache for availability rule posts
			_prime_post_caches( $all_rule_ids );
			// Prime meta cache to avoid individual meta queries
			update_meta_cache( 'post', $all_rule_ids );
		}

		// Second pass: Load availability rule objects (now using primed caches)
		foreach ( $scopes as $scope ) {
			$kind = $scope['kind'] ?? '';
			$rules_by_id = $scope_rules_map[ $kind ] ?? [];

			// Load actual availability rule objects to get full rule data (including time fields)
			$unavailable = [];
			$available = [];

			foreach ( $rules_by_id as $rule_id => $row ) {
				try {
					// Now this will use primed cache instead of individual database queries
					$availability = new WC_Appointments_Availability( $rule_id );
					$rule_data = $availability->get_data();
					$rule_data['id'] = $availability->get_id();

					// Get appointable status from cache row (it's already filtered correctly)
					$appointable = $row['appointable'] ?? 'yes';
					// Ensure appointable is boolean (not 'yes'/'no')
					$rule_data['appointable'] = ( 'no' !== $appointable );

					// Note: We return the original rule's dates (from_date, to_date) and time fields (from_range, to_range, rrule)
					// The JavaScript will expand these rules based on the time window
					// We don't override with occurrence dates because the frontend needs the original rule definition

					if ( 'no' === $appointable ) {
						$unavailable[] = $rule_data;
					} else {
						$available[] = $rule_data;
					}
				} catch ( Exception $e ) {
					// Rule not found, skip
					continue;
				}
			}

			// Store results keyed by scope kind for easy lookup
			$results[ $kind ] = [
				'unavailable' => $unavailable,
				'available' => $available,
			];
		}

		return rest_ensure_response( $results );
	}

	/**
	 * Convert cache row to availability rule format (for backward compatibility).
	 *
	 * @param array $row Cache row from indexed table.
	 * @return array Availability rule in v1 format.
	 */
	protected function convert_cache_row_to_availability_rule( $row ): array {
		// Map cache row fields to availability rule format
		// This maintains backward compatibility with v1 API responses
		$rule = [
			'id' => absint( $row['source_id'] ?? 0 ),
			'kind' => 'availability#' . ( $row['scope'] ?? WC_Appointments_Availability::SCOPE_GLOBAL ),
			'kind_id' => 0,
			'range_type' => $row['range_type'] ?? '',
			'appointable' => ( 'yes' === ( $row['appointable'] ?? '' ) ),
			'priority' => absint( $row['priority'] ?? 10 ),
			'qty' => absint( $row['qty'] ?? 0 ),
			'ordering' => absint( $row['ordering'] ?? 0 ),
		];

		// Set kind_id based on scope
		if ( isset( $row['scope'] ) ) {
			if ( WC_Appointments_Availability::SCOPE_PRODUCT === $row['scope'] && isset( $row['product_id'] ) ) {
				$rule['kind_id'] = absint( $row['product_id'] );
			} elseif ( WC_Appointments_Availability::SCOPE_STAFF === $row['scope'] && isset( $row['staff_id'] ) ) {
				$rule['kind_id'] = absint( $row['staff_id'] );
			}
		}

		// Convert timestamps to dates if available
		if ( isset( $row['start_ts'] ) && 0 < $row['start_ts'] ) {
			$rule['from_date'] = date( 'Y-m-d', $row['start_ts'] );
		}
		if ( isset( $row['end_ts'] ) && 0 < $row['end_ts'] ) {
			$rule['to_date'] = date( 'Y-m-d', $row['end_ts'] );
		}

		return $rule;
	}

	/**
	 * Convert multiple cache rows to availability rules format.
	 *
	 * @param array $rows Array of cache rows.
	 * @return array Array of availability rules.
	 */
	protected function convert_cache_rows_to_availability_rules( $rows ): array {
		return array_map( [ $this, 'convert_cache_row_to_availability_rule' ], $rows );
	}

	/**
	 * Retrieve a single availability rule by id.
	 */
	public function get_item( $request ): WP_REST_Response|WP_Error {
		$id = absint( $request['id'] );
		if ( 0 >= $id ) {
			return new WP_Error( 'woocommerce_appointments_availability_invalid_id', 'Invalid availability id.', [ 'status' => 400 ] );
		}

		try {
			$object = new WC_Appointments_Availability( $id ); // Constructor loads data via data store.
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_appointments_availability_not_found', 'Availability not found.', [ 'status' => 404 ] );
		}

		return rest_ensure_response( $this->prepare_item_for_response( $object, $request ) );
	}

	/**
	 * Create a new availability rule.
	 */
	public function create_item( $request ): WP_REST_Response {
		$object = new WC_Appointments_Availability();
		$props  = $this->build_props_from_request( $request );
		$object->set_props( $props );
		$object->save();
		return rest_ensure_response( $this->prepare_item_for_response( $object, $request ) );
	}

	/**
	 * Update an existing availability rule.
	 */
	public function update_item( $request ): WP_REST_Response|WP_Error {
		$id = absint( $request['id'] );
		if ( 0 >= $id ) {
			return new WP_Error( 'woocommerce_appointments_availability_invalid_id', 'Invalid availability id.', [ 'status' => 400 ] );
		}

		try {
			$object = new WC_Appointments_Availability( $id ); // Constructor loads data via data store.
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_appointments_availability_not_found', 'Availability not found.', [ 'status' => 404 ] );
		}

		$props = $this->build_props_from_request( $request );
		$object->set_props( $props );
		$object->save();
		return rest_ensure_response( $this->prepare_item_for_response( $object, $request ) );
	}

	/**
	 * Delete an availability rule.
	 */
	public function delete_item( $request ): WP_REST_Response|WP_Error {
		$id = absint( $request['id'] );
		if ( 0 >= $id ) {
			return new WP_Error( 'woocommerce_appointments_availability_invalid_id', 'Invalid availability id.', [ 'status' => 400 ] );
		}

		try {
			$object = new WC_Appointments_Availability( $id ); // Constructor loads data via data store.
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_appointments_availability_not_found', 'Availability not found.', [ 'status' => 404 ] );
		}

		$object->delete();
		return rest_ensure_response( [ 'deleted' => true, 'id' => $id ] );
	}

	/**
	 * Basic permission checks; align with appointments controller policy.
	 */
	public function create_item_permissions_check( $request ): bool {
		return current_user_can( 'manage_woocommerce' );
	}

	public function update_item_permissions_check( $request ): bool {
		return current_user_can( 'manage_woocommerce' );
	}

	public function delete_item_permissions_check( $request ): bool {
		return current_user_can( 'manage_woocommerce' );
	}

	/**
	 * Permission check for reading a single availability rule.
	 *
	 * Mirrors v1 behavior by allowing public read access. This avoids
	 * the default WP_REST_Controller "invalid-method" error and ensures
	 * single-item GETs are accessible without authentication.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool
	 */
	public function get_item_permissions_check( $request ): bool {
		return true; // Anyone can read single availability rules.
	}

	/**
	 * Build props from request payload into WC_Appointments_Availability fields.
	 *
	 * Keeps names consistent with WC_Appointments_Availability_Data_Store.
	 */
	protected function build_props_from_request( WP_REST_Request $request ): array {
		$int   = (static fn($val) => is_null( $val ) ? null : absint( $val ));
		$str   = (static fn($val) => is_null( $val ) ? null : sanitize_text_field( $val ));
		$date  = static function( $val ) {
			if ( is_null( $val ) ) { return null; }
			if ( is_numeric( $val ) ) { return date( 'Y-m-d', (int) $val ); }
			return sanitize_text_field( $val );
		};
		$yn    = static function( $val ): ?string {
			if ( is_null( $val ) ) { return null; }
			return wc_string_to_bool( $val ) ? 'yes' : 'no';
		};

		$props = [];
		// Core identity and relation.
		$props['kind']       = $str( $request->get_param( 'kind' ) );
		$props['kind_id']    = $int( $request->get_param( 'kind_id' ) );
		$props['event_id']   = $int( $request->get_param( 'event_id' ) );
		$props['title']      = $str( $request->get_param( 'title' ) );
		// Range and window.
		$props['range_type'] = $str( $request->get_param( 'range_type' ) );
		$props['from_date']  = $date( $request->get_param( 'from_date' ) );
		$props['to_date']    = $date( $request->get_param( 'to_date' ) );
		$props['from_range'] = $str( $request->get_param( 'from_range' ) );
		$props['to_range']   = $str( $request->get_param( 'to_range' ) );
		// Availability and limits.
		$props['appointable'] = $yn( $request->get_param( 'appointable' ) );
		$props['priority']    = $int( $request->get_param( 'priority' ) );
		$props['qty']         = $int( $request->get_param( 'qty' ) );
		$props['ordering']    = $int( $request->get_param( 'ordering' ) );
		// Recurrence rule (RFC 5545-like).
		$props['rrule']       = $str( $request->get_param( 'rrule' ) );

		return array_filter( $props, static fn($v): bool => ! is_null( $v ) );
	}

	/**
	 * Prepare response from WC_Appointments_Availability object.
	 */
	public function prepare_item_for_response( $object, $request ): array {
		$data = $object->get_data();
		$data['id'] = $object->get_id();
		return $data;
	}

	/**
	 * Collection params keep parity with v1 but add optimized parameters.
	 *
	 * @return array
	 */
	public function get_collection_params(): array {
		$params = parent::get_collection_params();

		// Allow `filter` as arbitrary query constraints for data store (v1 compatibility).
		$params['filter'] = [
			'description' => 'Filter query used by the availability data store (v1 compatibility).',
			'type'        => 'array',
		];

		// Optimized parameters for indexed cache queries.
		$params['start_ts'] = [
			'description' => 'Start timestamp (UTC epoch seconds) for time window filtering. Used with indexed cache.',
			'type'        => 'integer',
		];

		$params['end_ts'] = [
			'description' => 'End timestamp (UTC epoch seconds) for time window filtering. Used with indexed cache.',
			'type'        => 'integer',
		];

		$params['product_id'] = [
			'description' => 'Filter by product ID. Used with indexed cache.',
			'type'        => 'integer',
		];

		$params['staff_id'] = [
			'description' => 'Filter by staff ID. Used with indexed cache.',
			'type'        => 'integer',
		];

		$params['scope'] = [
			'description' => 'Filter by scope (global, product, staff) or array of scopes. Used with indexed cache.',
			'type'        => [ 'string', 'array' ],
		];

		$params['appointable'] = [
			'description' => 'Filter by appointable status (true = available, false = unavailable). Used with indexed cache.',
			'type'        => 'boolean',
		];

		$params['scopes'] = [
			'description' => 'Array of scope objects for batch fetching. Each object should have "kind" (e.g., "availability#global") and optional "kind_id". Can be provided as JSON string (from JavaScript) or array (from PHP). Optimized for admin calendar use case.',
			'type'        => [ 'string', 'array' ], // Accept both JSON string and array
			'items'       => [
				'type'       => 'object',
				'properties' => [
					'kind'    => [
						'type'        => 'string',
						'description' => 'Scope kind (e.g., "availability#global", "availability#product", "availability#staff").',
					],
					'kind_id' => [
						'type'        => [ 'integer', 'null' ],
						'description' => 'Associated object ID for kind (product ID for product scope, staff ID for staff scope, null for global).',
					],
				],
			],
		];

		return $params;
	}

	/**
	 * JSON Schema for availability rule payload.
	 *
	 * This documents keys used by the data store and helps client validation.
	 */
	public function get_item_schema(): array {
		return [
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'appointments_availability',
			'type'       => 'object',
			'properties' => [
				'id'          => [ 'description' => 'Availability rule ID.', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'kind'        => [ 'description' => 'Kind of availability (e.g. availability#global, availability#product, availability#staff).', 'type' => 'string', 'context' => [ 'view', 'edit' ] ],
				'kind_id'     => [ 'description' => 'Associated object ID for kind (product, staff, etc.).', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'event_id'    => [ 'description' => 'Associated event ID if any.', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'title'       => [ 'description' => 'Human readable title.', 'type' => 'string', 'context' => [ 'view', 'edit' ] ],
				'range_type'  => [ 'description' => 'Range type (e.g., custom, months, weeks, days, time).', 'type' => 'string', 'context' => [ 'view', 'edit' ] ],
				'from_date'   => [ 'description' => 'Range start date/time (timestamp).', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'to_date'     => [ 'description' => 'Range end date/time (timestamp).', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'from_range'  => [ 'description' => 'Time window start (e.g., 09:00).', 'type' => 'string', 'context' => [ 'view', 'edit' ] ],
				'to_range'    => [ 'description' => 'Time window end (e.g., 17:00).', 'type' => 'string', 'context' => [ 'view', 'edit' ] ],
				'appointable' => [ 'description' => 'Whether the range is appointable.', 'type' => 'boolean', 'context' => [ 'view', 'edit' ] ],
				'priority'    => [ 'description' => 'Rule priority for conflict resolution.', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'qty'         => [ 'description' => 'Quantity allowed within range.', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'ordering'    => [ 'description' => 'Ordering index for rule display.', 'type' => 'integer', 'context' => [ 'view', 'edit' ] ],
				'rrule'       => [ 'description' => 'Recurrence rule (RFC 5545-like).', 'type' => 'string', 'context' => [ 'view', 'edit' ] ],
			],
		];
	}
}