<?php
/**
 * REST API for appointments (v1).
 *
 * Handles requests to the `/appointments` endpoint for managing
 * `wc_appointment` posts via full CRUD.
 *
 * Endpoints
 * - `GET /{namespace}/appointments`
 * - `GET /{namespace}/appointments/{id}`
 * - `POST /{namespace}/appointments`
 * - `PUT|PATCH /{namespace}/appointments/{id}`
 * - `DELETE /{namespace}/appointments/{id}`
 * - `POST /{namespace}/appointments/batch`
 *
 * Permissions
 * - Read: Published and public statuses are readable; the permission trait
 *   allows broader read for published items while respecting core caps.
 * - Mutations: Require `manage_woocommerce`.
 *
 * Collection Filters
 * - `product_id` (int), `staff_id` (int), `customer_id` (int)
 * - `date_from` (string), `date_to` (string) — parsed via `strtotime` and
 *   applied to meta keys `_appointment_start`/`_appointment_end` (format `YmdHis`).
 * - Standard Woo parameters also apply (e.g., `page`, `per_page`, `order`).
 *
 * Response Fields (selected)
 * - `id`, `all_day`, `cost`, `customer_id`, `date_created`, `date_modified`,
 *   `start`, `end`, `product_id`, `staff_ids`, `status`, `customer_status`,
 *   `qty`, `timezone`, `local_timezone`, calendar metadata.
 *
 * Create/Update Fields (subset)
 * - Core identifiers, scheduling (`start`, `end`, `all_day`), staff assignment(s),
 *   customer, quantity, pricing, calendar metadata, timezone flags, meta data.
 *
 * @package WooCommerce\Appointments\Rest\Controller
 */

/**
 * REST API Products controller class.
 */
class WC_Appointments_REST_Appointments_Controller extends WC_Appointments_REST_CRUD_Controller {

	use WC_Appointments_Rest_Permission_Check;

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

	/**
	 * Post type.
	 *
	 * @var string
	 */
	protected $post_type = 'wc_appointment';

	/**
	 * Get object.
	 *
	 * @param int $appointment_id Object ID.
	 *
	 * @return WC_Appointment|false
	 */
	protected function get_object( $appointment_id ): WC_Appointment|false {
		$post_object = $appointment_id ? get_post( $appointment_id ) : false;

		if ( ! $post_object || $this->post_type !== $post_object->post_type ) {
			return false;
		}

		return get_wc_appointment( $appointment_id );
	}

	/**
	 * Get objects (i.e. Appointments).
	 *
	 * @param array $query_args Query args.
	 *
	 * @return array Appointments data.
	 */
	protected function get_objects( $query_args ): array {
		/**
		 * Get all public post statuses list and include a few.
		 * This is done to include `wc-partial-payment` for now.
		 *
		 */
		if ( ! isset( $query_args['post_status'] ) && empty( $query_args['post_status'] ) ) {
			$post_statuses             = array_values( get_post_stati( [ 'public' => true ] ) );
			$include_statuses          = [ 'wc-partial-payment' ];
			$query_args['post_status'] = array_merge( $post_statuses, $include_statuses );
		}

		return parent::get_objects( $query_args );
	}

	/**
	 * Prepare objects query.
	 *
	 * @since  3.7.2
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return array
	 */
	protected function prepare_objects_query( $request ): array {
		/**
		 * Build WP_Query args for appointment collection.
		 *
		 * Filters
		 * - `product_id`: matches `_appointment_product_id` meta.
		 * - `staff_id`: matches `_appointment_staff_id` meta.
		 * - `customer_id`: matches `_appointment_customer_id` meta.
		 * - `date_from`: clamps by `_appointment_start >= YmdHis`.
		 * - `date_to`: clamps by `_appointment_end < YmdHis`.
		 *
		 * @param  WP_REST_Request $request Full details about the request.
		 * @return array
		 */
		$args = parent::prepare_objects_query( $request );

		// Meta query.
		$meta_query = [];

		// Filter by product.
		if ( isset( $request['product_id'] ) ) {
			$meta_query[] = [
				'key'   => '_appointment_product_id',
				'value' => absint( $request['product_id'] ),
			];
		}

		// Filter by staff.
		if ( isset( $request['staff_id'] ) ) {
			$meta_query[] = [
				'key'   => '_appointment_staff_id',
				'value' => absint( $request['staff_id'] ),
			];
		}

		// Filter by customer.
		if ( isset( $request['customer_id'] ) ) {
			$meta_query[] = [
				'key'   => '_appointment_customer_id',
				'value' => absint( $request['customer_id'] ),
			];
		}

		// Filter by date range - use overlap logic to include all appointments that intersect the range
		// When both date_from and date_to are provided, check for overlap: start < date_to AND end > date_from
		// This ensures appointments that start before or end after the range are still included if they overlap
		if ( isset( $request['date_from'] ) && isset( $request['date_to'] ) ) {
			$date_from_ts = strtotime( $request['date_from'] );
			$date_to_ts   = strtotime( $request['date_to'] );

			if ( $date_from_ts && $date_to_ts && $date_to_ts > $date_from_ts ) {
				$date_from_str = esc_sql( date( 'YmdHis', $date_from_ts ) );
				$date_to_str   = esc_sql( date( 'YmdHis', $date_to_ts ) );

				// Use overlap logic: appointment overlaps if start < date_to AND end > date_from
				// This is more complex than a simple meta_query, so we use a nested query
				$meta_query[] = [
					'relation' => 'AND',
					[
						'key'     => '_appointment_start',
						'value'   => $date_to_str,
						'compare' => '<',
					],
					[
						'key'     => '_appointment_end',
						'value'   => $date_from_str,
						'compare' => '>',
					],
				];
			}
		} elseif ( isset( $request['date_from'] ) ) {
			// Only date_from: show appointments that end after date_from (overlap or start after)
			$meta_query[] = [
				'key'     => '_appointment_end',
				'value'   => esc_sql( date( 'YmdHis', strtotime( $request['date_from'] ) ) ),
				'compare' => '>',
			];
		} elseif ( isset( $request['date_to'] ) ) {
			// Only date_to: show appointments that start before date_to (overlap or end before)
			$meta_query[] = [
				'key'     => '_appointment_start',
				'value'   => esc_sql( date( 'YmdHis', strtotime( $request['date_to'] ) ) ),
				'compare' => '<',
			];
		}

		if ( $meta_query && [] !== $meta_query ) {
			$args['meta_query'] = [
				'relation' => 'AND',
				$meta_query,
			];
		}

		return $args;
	}

	/**
	 * Prepare a single appointment output for response.
	 *
	 * @param WC_Appointment      $object  Object data.
	 * @param WP_REST_Request $request Request object.
	 *
	 * @return WP_REST_Response
	 */
	public function prepare_object_for_response( $object, $request ): WP_REST_Response {
		/**
		 * Format an appointment for API response.
		 *
		 * Includes key fields for schedule, identity, pricing, staff, status,
		 * and timezone data. Filters additional fields and respects `context`.
		 *
		 * @param WC_Appointment   $object  Appointment object.
		 * @param WP_REST_Request  $request Request object.
		 * @return WP_REST_Response
		 */
		$context = empty( $request['context'] ) ? 'view' : $request['context'];

		$data = [
			'id'                              => $object->get_id( $context ),
			'all_day'                         => $object->get_all_day( $context ),
			'cost'                            => $object->get_cost( $context ),
			'customer_id'                     => $object->get_customer_id( $context ),
			'date_created'                    => $object->get_date_created( $context ),
			'date_modified'                   => $object->get_date_modified( $context ),
			'start'                           => $object->get_start( $context ),
			'end'                             => $object->get_end( $context ),
			// Provide explicit UTC-normalized timestamps for external consumers.
			// Conversion uses the site timezone (IANA or offset) at the given moment.
			// Example: site tz Europe/Berlin, start 1766674800 -> start_utc 1766671200.
			'start_utc'                       => $this->to_utc_using_site_timezone( (int) $object->get_start( $context ) ),
			'end_utc'                         => $this->to_utc_using_site_timezone( (int) $object->get_end( $context ) ),
			'google_calendar_event_id'        => $object->get_google_calendar_event_id( $context ),
			'google_calendar_staff_event_ids' => $object->get_google_calendar_staff_event_ids( $context ),
			'order_id'                        => $object->get_order_id( $context ),
			'order_item_id'                   => $object->get_order_item_id( $context ),
			'parent_id'                       => $object->get_parent_id( $context ),
			'product_id'                      => $object->get_product_id( $context ),
			'staff_id'                        => $object->get_staff_ids( $context ),
			'staff_ids'                       => $object->get_staff_ids( $context ),
			'status'                          => $object->get_status( $context ),
			'customer_status'                 => $object->get_customer_status( $context ),
			'qty'                             => $object->get_qty( $context ),
			'timezone'                        => $object->get_timezone( $context ),
			'local_timezone'                  => $object->get_local_timezone( $context ),
		];

		$data     = $this->add_additional_fields_to_object( $data, $request );
		$data     = $this->filter_response_by_context( $data, $context );
		$response = rest_ensure_response( $data );
		$response->add_links( $this->prepare_links( $object, $request ) );

		/**
		 * Filter the data for a response.
		 *
		 * The dynamic portion of the hook name, $this->post_type,
		 * refers to object type being prepared for the response.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param WC_Data          $object   Object data.
		 * @param WP_REST_Request  $request  Request object.
		 */
		return apply_filters( "woocommerce_rest_prepare_{$this->post_type}_object", $response, $object, $request );
	}

	/**
	 * Prepare a single product for create or update.
	 *
	 * @param  WP_REST_Request $request Request object.
	 * @param  bool            $creating If is creating a new object.
	 * @return WP_Error|WC_Data
	 */
	protected function prepare_object_for_database( $request, $creating = false ): WP_Error|WC_Data {
		/**
		 * Map request payload into a WC_Appointment for create/update.
		 *
		 * Validates status transitions, sets identifiers, scheduling window,
		 * staff and customer relationships, quantity, pricing, calendar metadata,
		 * timezone flags, and arbitrary meta.
		 *
		 * @param  WP_REST_Request $request  Request object.
		 * @param  bool            $creating Creating a new object if true.
		 * @return WP_Error|WC_Data
		 */
		// Resolve appointment by id (0 means new object; data store will create on save).
		$id = absint( $request['id'] ?? 0 );
		$appointment = get_wc_appointment( $id );

		// Map simple, top-level fields directly to WC_Appointment setters.
		// This lets clients send the same keys returned by GET, e.g. { "status": "unpaid" }.
		// Keep values loosely typed; setters handle normalization (timestamps, ints, bools).

		// Status validation to prevent invalid transitions.
		// Allowed values come from `get_wc_appointment_statuses( 'validate' )` and include:
		// `unpaid`, `pending-confirmation`, `confirmed`, `paid`, `cancelled`.
		// Extendable via `woocommerce_appointment_statuses_for_validation` filter; invalid
		// values return `woocommerce_appointments_invalid_status` (HTTP 400).
		if ( isset( $request['status'] ) ) {
			$valid_statuses = function_exists( 'get_wc_appointment_statuses' ) ? get_wc_appointment_statuses( 'validate' ) : [];
			$next_status    = sanitize_text_field( $request['status'] );
			if ( [] !== $valid_statuses && ! in_array( $next_status, $valid_statuses, true ) ) {
				return new WP_Error( 'woocommerce_appointments_invalid_status', 'Invalid appointment status.', [ 'status' => 400 ] );
			}
			$appointment->set_status( $next_status );
		}

		// Core identifiers and associations.
		if ( isset( $request['product_id'] ) ) {
			$appointment->set_product_id( $request['product_id'] );
		}
		if ( isset( $request['customer_id'] ) ) {
			$appointment->set_customer_id( $request['customer_id'] );
		}
		if ( isset( $request['order_id'] ) ) {
			$appointment->set_order_id( $request['order_id'] );
		}
		if ( isset( $request['order_item_id'] ) ) {
			$appointment->set_order_item_id( $request['order_item_id'] );
		}
		if ( isset( $request['parent_id'] ) ) {
			$appointment->set_parent_id( $request['parent_id'] );
		}

		// Scheduling and duration.
		// Human‑readable datetime support: `start_human` / `end_human` are parsed in UTC.
		// The `timezone` and `local_timezone` fields are stored as declarative metadata only.
		// When only one of start/end is present, infer the missing one using the appointable product’s
		// configured duration and unit using UTC calendar adjustments.

		// Global parsing semantics: treat site timezone as GMT/UTC for conversions.
		// Use UTC for all parsing and calendar-aware adjustments to avoid ambiguous DST behavior.
		$utc_tz = new DateTimeZone( 'UTC' );

		// Determine declared timezone (if any) and prepare a safe DateTimeZone or offset.
		$declared_tz_string = '';
		if ( isset( $request['timezone'] ) && is_string( $request['timezone'] ) ) {
			$declared_tz_string = sanitize_text_field( $request['timezone'] );
		} elseif ( isset( $request['local_timezone'] ) && is_string( $request['local_timezone'] ) ) {
			$declared_tz_string = sanitize_text_field( $request['local_timezone'] );
		}

		// Calendar‑aware operations should be performed in UTC.
		$preferred_tz = $utc_tz; // DateTimeZone to use for calendar‑aware ops (UTC).

		// Parse human‑readable datetimes in UTC. The declared timezone is stored as metadata only.
		if ( isset( $request['start_human'] ) || isset( $request['end_human'] ) ) {
			if ( isset( $request['start_human'] ) ) {
				$start_str = sanitize_text_field( $request['start_human'] );
				try {
					// Parse strictly as UTC so that "14:00" maps to 14:00Z.
					$dt          = new DateTimeImmutable( $start_str, $utc_tz );
					$start_epoch = $dt->getTimestamp();
					$appointment->set_start( $start_epoch );
				} catch ( Exception $e ) {
					return new WP_Error( 'woocommerce_appointments_invalid_datetime', 'Invalid start_human datetime format.', [ 'status' => 400 ] );
				}
			}

			if ( isset( $request['end_human'] ) ) {
				$end_str = sanitize_text_field( $request['end_human'] );
				try {
					// Parse strictly as UTC so that "end" maps to given clock time in Z.
					$dt        = new DateTimeImmutable( $end_str, $utc_tz );
					$end_epoch = $dt->getTimestamp();
					$appointment->set_end( $end_epoch );
				} catch ( Exception $e ) {
					return new WP_Error( 'woocommerce_appointments_invalid_datetime', 'Invalid end_human datetime format.', [ 'status' => 400 ] );
				}
			}
		}

		// Fallback: numeric/strtotime‑compatible values in `start`/`end` if human values not provided.
		if ( ! isset( $request['start_human'] ) && isset( $request['start'] ) ) {
			if ( is_string( $request['start'] ) ) {
				// Parse string as UTC to align with global UTC semantics.
				try {
					$dt          = new DateTimeImmutable( sanitize_text_field( $request['start'] ), $utc_tz );
					$start_epoch = $dt->getTimestamp();
					$appointment->set_start( $start_epoch );
				} catch ( Exception $e ) {
					// Fall back to original setter behavior if parsing fails.
					$appointment->set_start( $request['start'] );
				}
			} else {
				$appointment->set_start( $request['start'] );
			}
		}
		if ( ! isset( $request['end_human'] ) && isset( $request['end'] ) ) {
			if ( is_string( $request['end'] ) ) {
				try {
					$dt        = new DateTimeImmutable( sanitize_text_field( $request['end'] ), $utc_tz );
					$end_epoch = $dt->getTimestamp();
					$appointment->set_end( $end_epoch );
				} catch ( Exception $e ) {
					$appointment->set_end( $request['end'] );
				}
			} else {
				$appointment->set_end( $request['end'] );
			}
		}
		if ( isset( $request['all_day'] ) ) {
			$appointment->set_all_day( wc_string_to_bool( $request['all_day'] ) );
		}

		// Infer missing start/end using the product's duration when creating.
		if ( $creating ) {
			$start_ts = (int) $appointment->get_start( 'edit' );
			$end_ts   = (int) $appointment->get_end( 'edit' );
			$product_id_for_duration = (int) $appointment->get_product_id( 'edit' );
			if ( 0 < $product_id_for_duration && ( ( 0 < $start_ts && 0 >= $end_ts ) || ( 0 < $end_ts && 0 >= $start_ts ) ) ) {
				$product_for_duration = wc_get_product( $product_id_for_duration );
				if ( $product_for_duration && method_exists( $product_for_duration, 'get_duration' ) && method_exists( $product_for_duration, 'get_duration_unit' ) ) {
					$duration     = max( 1, (int) $product_for_duration->get_duration() );
					$duration_unit = (string) $product_for_duration->get_duration_unit();

					// Helper to apply duration to a base timestamp using calendar‑aware adjustments in the preferred timezone.
					$apply_duration = function( $base_ts, string $units, $unit, $add = true ) use ( $preferred_tz ): int {
						$dt = ( new DateTimeImmutable( '@' . (int) $base_ts ) )->setTimezone( $preferred_tz );
					switch ( $unit ) {
						case WC_Appointments_Constants::DURATION_MONTH:
							$modifier = ( $add ? '+' : '-' ) . $units . ' month';
							$dt       = $dt->modify( $modifier );
							break;
						case WC_Appointments_Constants::DURATION_DAY:
							$modifier = ( $add ? '+' : '-' ) . $units . ' day';
							$dt       = $dt->modify( $modifier );
							break;
						case WC_Appointments_Constants::DURATION_NIGHT:
							$modifier = ( $add ? '+' : '-' ) . $units . ' day';
							$dt       = $dt->modify( $modifier );
							// Nights are day‑based but end one second earlier; reverse when subtracting.
							$dt       = $add ? $dt->modify( '-1 second' ) : $dt->modify( '+1 second' );
							break;
						case WC_Appointments_Constants::DURATION_HOUR:
							$seconds  = $units * 3600;
							$modifier = ( $add ? '+' : '-' ) . $seconds . ' second';
							$dt       = $dt->modify( $modifier );
							break;
						case WC_Appointments_Constants::DURATION_MINUTE:
						default:
							$seconds  = $units * 60;
							$modifier = ( $add ? '+' : '-' ) . $seconds . ' second';
							$dt       = $dt->modify( $modifier );
					}
						return $dt->getTimestamp();
					};

					if ( 0 < $start_ts && 0 >= $end_ts ) {
						$computed_end = $apply_duration( $start_ts, $duration, $duration_unit, true );
						$appointment->set_end( $computed_end );
					} elseif ( 0 < $end_ts && 0 >= $start_ts ) {
						$computed_start = $apply_duration( $end_ts, $duration, $duration_unit, false );
						$appointment->set_start( $computed_start );
					}
				}
			}
		}

		// Staff: support both `staff_ids` (array) and `staff_id` (single) to match GET.
		if ( isset( $request['staff_ids'] ) ) {
			$appointment->set_staff_ids( $request['staff_ids'] );
		} elseif ( isset( $request['staff_id'] ) ) {
			$appointment->set_staff_id( $request['staff_id'] );
		}

		// Customer status and quantity.
		if ( isset( $request['customer_status'] ) ) {
			$appointment->set_customer_status( sanitize_text_field( $request['customer_status'] ) );
		}
		if ( isset( $request['qty'] ) ) {
			$appointment->set_qty( $request['qty'] );
		}

		// Pricing and calendar metadata.
		if ( isset( $request['cost'] ) ) {
			$appointment->set_cost( $request['cost'] );
		}
		if ( isset( $request['google_calendar_event_id'] ) ) {
			$appointment->set_google_calendar_event_id( sanitize_text_field( $request['google_calendar_event_id'] ) );
		}
		if ( isset( $request['google_calendar_staff_event_ids'] ) ) {
			$appointment->set_google_calendar_staff_event_ids( $request['google_calendar_staff_event_ids'] );
		}

		// Timezone fields.
		if ( isset( $request['timezone'] ) ) {
			$appointment->set_timezone( sanitize_text_field( $request['timezone'] ) );
		}
		if ( isset( $request['local_timezone'] ) ) {
			$appointment->set_local_timezone( sanitize_text_field( $request['local_timezone'] ) );
		}

		// Optional explicit timestamps for bookkeeping.
		if ( isset( $request['date_created'] ) ) {
			$appointment->set_date_created( $request['date_created'] );
		}
		if ( isset( $request['date_modified'] ) ) {
			$appointment->set_date_modified( $request['date_modified'] );
		}

		// Backward-compatible meta_data updates using key/value pairs.
		if ( isset( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
			foreach ( $request['meta_data'] as $meta ) {
				$meta_id = $meta['id'] ?? '';
				$appointment->update_meta_data( $meta['key'], $meta['value'], $meta_id );
			}
		}

		/**
		 * Filters an object before it is inserted via the REST API.
		 *
		 * The dynamic portion of the hook name, `$this->post_type`,
		 * refers to the object type slug.
		 *
		 * @param WC_Data         $appointment  Object object.
		 * @param WP_REST_Request $request  Request object.
		 * @param bool            $creating If is creating a new object.
		 */
		return apply_filters( "woocommerce_rest_pre_insert_{$this->post_type}_object", $appointment, $request, $creating );
	}

	/**
	 * Create a new appointment. If `order_id` is not provided, also create a corresponding order
	 * with a single line item for the appointment product and link it back to the appointment.
	 *
	 * Key behaviors:
	 * - Creates the `wc_appointment` object from request payload and saves it.
	 * - If no `order_id` was specified, creates a WooCommerce order, adds the product
	 * 	as a line item using `qty` and `cost` when available, links `order_id` and
	 * 	`order_item_id` back to the appointment, and saves again.
	 * - Returns the standard appointment response payload including `order_id` and `order_item_id` when auto‑created.
	 * - When only one of `start`/`end` (or `start_human`/`end_human`) is provided at creation, the controller infers the missing value using the product’s duration and unit.
	 *
	 * Request parameters (selected):
	 * - `product_id` (int, required): Appointable product ID.
	 * - `start` (int|string, required): UNIX timestamp or `strtotime`‑parseable string.
	 * - `end` (int|string, required): UNIX timestamp or `strtotime`‑parseable string.
	 * - `start_human` / `end_human` (string, optional): Human‑readable datetimes (e.g., "2025-12-01 10:00"). Parsed strictly in UTC; override `start`/`end` when present.
	 *   `timezone`/`local_timezone` are stored as metadata on the appointment (declarative only) and do not affect conversion.
	 * - `status` (string, optional): Appointment status (e.g., `confirmed`, `unpaid`, etc.).
	 * - `customer_id` (int, optional).
	 * - `qty` (int, optional, default 1).
	 * - `all_day` (bool, optional).
	 * - `staff_id` (int, optional) or `staff_ids` (array<int>, optional).
	 * - `cost` (number, optional): Line amount to use for the order item.
	 * - `cost_includes_tax` (bool, optional, default false): When `cost` is provided, if true the value is treated as tax‑inclusive and tax is removed to set exclusive totals; if false, `cost` is treated as exclusive of tax.
	 * - `timezone`, `local_timezone` (string, optional).
	 * - `order_id` (int, optional): If provided, skips auto‑order creation and simply links to the provided order.
	 * - `google_calendar_event_id` (string, optional), `google_calendar_staff_event_ids` (array<string>, optional).
	 * - `meta_data` (array of `{ key, value, id? }`, optional).
	 *
	 * Notes:
	 * - Payment is not processed here. The order is created in `pending` state.
	 * - When `cost` is omitted, the product’s price is used as the item total; taxes follow the product’s tax class.
	 * - If the product is non‑taxable, `cost_includes_tax` has no effect.
	 * - Human‑readable `start_human`/`end_human` are parsed in UTC and converted to absolute UNIX timestamps. `timezone`/`local_timezone` are stored but do not change the computed epoch.
	 *
	 * Examples:
	 *
	 * 1) Human‑readable start only (infer end, site timezone):
	 *    {
	 *    	"product_id": 1234,
	 *    	"start_human": "2025-12-01 10:00",
	 *    	"qty": 1
	 *    }
	 *    If the product duration is 1 hour, the controller stores end as 11:00 in the site timezone. If a `timezone` is provided, it is saved as a declaration but does not change timestamps.
	 *
	 * 1b) Human‑readable with timezone provided (conversion):
	 *    {
	 *    	"product_id": 1234,
	 *    	"start_human": "2025-12-01 10:00",
	 *    	"timezone": "Europe/Berlin",
	 *    	"qty": 1
	 *    }
	 *    `start` is stored at the epoch representing 10:00 in Berlin. If the site timezone differs, clients will still see consistent absolute timestamps and the UI will render site‑local display times.
	 *
	 * 2) Numeric end only (infer start):
	 *    {
	 *    	"product_id": 1234,
	 *    	"end": 1733048400,
	 *    	"qty": 1
	 *    }
	 *    If the product duration is 30 minutes, start is stored as 1733048400 - 1800.
	 *
	 * 3) Tax‑inclusive cost:
	 *    {
	 *    	"product_id": 1234,
	 *    	"start": "2025-12-01 10:00",
	 *    	"end": "2025-12-01 11:00",
	 *    	"qty": 2,
	 *    	"cost": 120.00,
	 *    	"cost_includes_tax": true
	 *    }
	 *    Tax is stripped from the provided cost to set exclusive totals per product tax class.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_Error|WP_REST_Response Standard REST response or error on failure.
	 */
	public function create_item( $request ): WP_REST_Response|WP_Error {
		// Prepare appointment object from request.
		$appointment = $this->prepare_object_for_database( $request, true );
		if ( is_wp_error( $appointment ) ) {
			return $appointment; // Early return on validation error.
		}

		// Save appointment first to obtain an ID.
		$appointment->save();

		// If request already provided an order, skip auto-creation.
		$order_id_in_request = absint( $request['order_id'] ?? 0 );
		if ( 0 < $order_id_in_request ) {
			return $this->prepare_object_for_response( $appointment, $request );
		}

		// Auto-create order only when product_id exists; otherwise, we cannot build a proper line item.
		$product_id = (int) $appointment->get_product_id( 'edit' );
		if ( 0 >= $product_id ) {
			return new WP_Error( 'woocommerce_appointments_missing_product', 'Cannot auto-create order: product_id is required.', [ 'status' => 400 ] );
		}

		// Create a new order.
		$order = wc_create_order();
		if ( ! $order || is_wp_error( $order ) ) {
			return new WP_Error( 'woocommerce_appointments_order_create_failed', 'Failed to create order for the appointment.', [ 'status' => 500 ] );
		}

		// Set order customer when available.
		$customer_id = (int) $appointment->get_customer_id( 'edit' );
		if ( 0 < $customer_id ) {
			$order->set_customer_id( $customer_id );
		}

		// Build the line item for the appointment product.
		$product = wc_get_product( $product_id );
		if ( ! $product ) {
			return new WP_Error( 'woocommerce_appointments_invalid_product', 'Invalid appointable product.', [ 'status' => 400 ] );
		}

		$qty  = (int) ( $appointment->get_qty( 'edit' ) ?: 1 );
		$cost = (float) ( $appointment->get_cost( 'edit' ) ?: 0 );
		// New request flag: when cost is provided, specify if it already includes tax.
		$cost_includes_tax = wc_string_to_bool( $request['cost_includes_tax'] ?? false );

		// Create order line item and set totals.
		$line_item = new WC_Order_Item_Product();
		$line_item->set_product( $product );
		$line_item->set_quantity( $qty );

		// If an explicit appointment cost is provided, honor the tax-inclusion flag.
		// Otherwise, default to product price * qty and let WooCommerce calculate taxes.
		$product_price = (float) wc_format_decimal( $product->get_price() );
		if ( 0 < $cost ) {
			// Determine tax rates for the product's tax class.
			$rates     = WC_Tax::get_rates( $product->get_tax_class() );
			$taxes     = WC_Tax::calc_tax( $cost, $rates, $cost_includes_tax );
			$total_tax = array_sum( $taxes );

			// Convert provided cost to exclusive-of-tax total if it included tax.
			$line_total = $cost_includes_tax ? max( 0, $cost - $total_tax ) : $cost;

			$line_item->set_subtotal( $line_total );
			$line_item->set_total( $line_total );
		} else {
			$line_total = $product_price * $qty;
			$line_item->set_subtotal( $line_total );
			$line_item->set_total( $line_total );
		}

		// Add the item to the order and persist.
		$order->add_item( $line_item );
		$order->calculate_totals();
		$order->save();

		// Link order back to the appointment (post_parent + meta for item ID).
		$order_item_id = $line_item->get_id();
		$appointment->set_order_id( $order->get_id() );
		$appointment->set_order_item_id( $order_item_id );
		$appointment->save();

		// Optionally cache appointment ID on the order item for faster lookups.
		// Not strictly required (data store populates it on demand), but helpful.
		wc_update_order_item_meta( $order_item_id, '_appointment_id', [ $appointment->get_id() ] );

		// Return the standard appointment response.
		return $this->prepare_object_for_response( $appointment, $request );
	}

	/**
	 * Extend the item schema with request‑only property `cost_includes_tax`.
	 *
	 * When creating an appointment and providing `cost`, this boolean indicates
	 * whether the value already includes tax. If true, tax is subtracted to derive
	 * exclusive totals before WooCommerce computes order taxes; otherwise the cost
	 * is treated as exclusive and taxes are added normally per product tax class.
	 *
	 * @return array Item schema enriched with `cost_includes_tax` in create context.
	 */
	public function get_item_schema(): array {
		// Extend parent schema to document the request-only flag `cost_includes_tax`.
		$schema = null;
		if ( ! isset( $schema['properties'] ) ) {
			$schema['properties'] = [];
		}

		// Clarify inference behavior for start/end when only one is provided.
		if ( isset( $schema['properties']['start'] ) && is_array( $schema['properties']['start'] ) ) {
			$schema['properties']['start']['description'] = 'Appointment start time as UNIX timestamp or `strtotime`-parseable string. If only `start` is provided at creation, `end` is inferred using the product’s duration and unit.';
		}
		if ( isset( $schema['properties']['end'] ) && is_array( $schema['properties']['end'] ) ) {
			$schema['properties']['end']['description'] = 'Appointment end time as UNIX timestamp or `strtotime`-parseable string. If only `end` is provided at creation, `start` is inferred using the product’s duration and unit.';
		}

		// Human-readable datetime fields (create-only).
		$schema['properties']['start_human'] = [
			'description' => 'Human-readable start datetime (e.g., "2025-12-01 10:00"). Parsed strictly in UTC. `timezone`/`local_timezone` are stored as metadata only and do not affect conversion. If only `start_human` is provided at creation, `end` is inferred using the product’s duration and unit.',
			'type'        => 'string',
			'context'     => [ 'create' ],
		];
		$schema['properties']['end_human'] = [
			'description' => 'Human-readable end datetime (e.g., "2025-12-01 11:00"). Parsed strictly in UTC. `timezone`/`local_timezone` are stored as metadata only and do not affect conversion. If only `end_human` is provided at creation, `start` is inferred using the product’s duration and unit.',
			'type'        => 'string',
			'context'     => [ 'create' ],
		];
		$schema['properties']['cost_includes_tax'] = [
			'description' => 'When `cost` is provided in the request, indicates whether it already includes tax. If true, taxes are subtracted to set exclusive totals; otherwise cost is treated as exclusive of tax.',
			'type'        => 'boolean',
			'default'     => false,
			'context'     => [ 'create' ],
		];

		// Read-only UTC-normalized convenience fields for external consumers.
		$schema['properties']['start_utc'] = [
			'description' => 'Appointment start expressed in UTC/GMT. Computed from `start` using the site timezone (including DST when applicable). Example: site tz `Europe/Berlin` and `start` 1766674800 -> `start_utc` 1766671200.',
			'type'        => 'integer',
			'context'     => [ 'view', 'edit' ],
			'readonly'    => true,
		];
		$schema['properties']['end_utc'] = [
			'description' => 'Appointment end expressed in UTC/GMT. Computed from `end` using the site timezone (including DST when applicable).',
			'type'        => 'integer',
			'context'     => [ 'view', 'edit' ],
			'readonly'    => true,
		];
		return $schema;
	}

	/**
	 * Convert a timestamp to UTC/GMT using the site timezone.
	 *
	 * This treats the stored timestamp as site-local and subtracts the site
	 * timezone offset at that moment to produce a UTC epoch. If the site uses
	 * an IANA timezone (e.g., Europe/Berlin), DST-aware offsets are applied.
	 * If the site is configured with a fixed UTC offset (e.g., UTC+2), that
	 * fixed offset is used.
	 *
	 * @param int $ts Epoch seconds.
	 * @return int UTC-normalized epoch seconds (0 when input is empty).
	 */
	protected function to_utc_using_site_timezone( $ts ): int {
		$ts = (int) $ts;
		if ( 0 >= $ts ) {
			return 0;
		}

		// Prefer WordPress site timezone when available.
		$site_tz = null;
		if ( function_exists( 'wp_timezone' ) ) {
			$site_tz = wp_timezone();
		} else {
			$tz_string = get_option( 'timezone_string' );
			if ( is_string( $tz_string ) && '' !== $tz_string ) {
				try {
					$site_tz = new DateTimeZone( $tz_string );
				} catch ( \Exception $e ) {
					$site_tz = null;
				}
			}
		}

		// DST-aware: use IANA timezone offset at this moment when possible.
		if ( $site_tz instanceof DateTimeZone ) {
			$offset = $site_tz->getOffset( new DateTimeImmutable( '@' . $ts ) );
			return $ts - (int) $offset;
		}

		// Fallback to fixed site offset when timezone string is not set.
		$offset_hours = (float) get_option( 'gmt_offset', 0 );
		$offset_secs  = (int) round( $offset_hours * 3600 );
		return $ts - $offset_secs;
	}
}
