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

/**
 * Main model class for all appointments.
 *
 * TIMEZONE ARCHITECTURE:
 * =====================
 * This class stores and manages appointment timestamps with specific timezone handling:
 *
 * Timestamp Storage:
 * - start, end: UTC Unix timestamps (seconds since epoch)
 * - These timestamps represent times in the site's configured timezone, not actual UTC
 * - When extracting components, use local methods (date(), format()) not UTC methods
 *
 * Timezone Metadata:
 * - timezone: Site timezone string (e.g., "Europe/Ljubljana")
 * - local_timezone: Customer timezone (if different, for future customer account support)
 *
 * Important Methods:
 * - get_start(), get_end(): Return UTC timestamps representing site timezone times
 * - set_start(), set_end(): Accept UTC timestamps representing site timezone times
 * - When working with these timestamps, extract components using local methods
 *
 * See TIMEZONE_ARCHITECTURE.md for detailed explanation.
 */
class WC_Appointment extends WC_Data implements WC_Appointments_Data_Object_Interface {

	/**
	 * Data array, with defaults.
	 *
	 * STAFF IDS ARCHITECTURE:
	 * =======================
	 * Staff assignment uses ONLY the 'staff_ids' property (not 'staff_id').
	 * This supports both single and multiple staff assignments.
	 *
	 * - Use get_staff_ids() to get all staff as an array
	 * - Use get_staff_id() to get the first/primary staff (convenience method)
	 * - Use set_staff_ids() or set_staff_id() to set staff (both write to 'staff_ids')
	 *
	 * The database stores staff in post meta '_appointment_staff_id' with multiple
	 * entries for multi-staff appointments.
	 *
	 * @var array
	 */
	protected $data = [
		'all_day'                         => false,
		'cost'                            => 0,
		'customer_id'                     => 0,
		'date_created'                    => '',
		'date_modified'                   => '',
		'start'                           => '',
		'end'                             => '',
		'google_calendar_event_id'        => 0,
		'google_calendar_staff_event_ids' => [],
		'order_id'                        => 0,
		'order_item_id'                   => 0,
		'parent_id'                       => 0,
		'product_id'                      => 0,
		'staff_ids'                       => [],  // Array of staff IDs (supports single or multiple staff)
		'status'                          => 'unpaid',
		'customer_status'                 => 'expected',
		'qty'                             => 1,
		'timezone'                        => '',
		'local_timezone'                  => '',
		'product'                         => '', // Cached product object (not persisted)
		'order'                           => '', // Cached order object (not persisted)
	];

	/**
	 * Object type and data store.
	 */
	protected $cache_group     = 'appointment';
	protected $data_store_name = 'appointment';
	protected $object_type     = 'appointment';

	/**
	 * Stores data about status changes so relevant hooks can be fired.
	 *
	 * @since 3.0.0
	 * @version 3.0.0
	 *
	 * @var bool|array False if it's not transitioned. Otherwise an array containing
	 *                 transitioned status 'from' and 'to'.
	 */
	protected $status_transitioned = false;

	/**
	 * Stores data about staff changes so relevant hooks can be fired.
	 *
	 * @since 4.22.0
	 * @version 4.22.0
	 *
	 * @var bool|array False if it's not transitioned. Otherwise an array containing
	 *                 transitioned status 'from' and 'to'.
	 */
	protected $staff_transitioned = false;

	/**
     * Cached start time.
     */
    protected ?int $start_cached = null;

	/**
     * Cached end time.
     */
    protected ?int $end_cached = null;

	/**
	 * Cached start time getter.
	 * This data needs to be set manually before it can be accessed.
	 * It also becomes available when the `is_within_slot` function is used.
	 *
	 * @since  4.26.0
	 *
	 * @return integer Appointment start timestamp.
	 */
	public function get_start_cached(): ?int {
		return $this->start_cached;
	}

	/**
	 * Cached end time getter.
	 * This data needs to be set manually before it can be accessed.
	 * It also becomes available when the `is_within_slot` function is used.
	 *
	 * @since  4.26.0
	 *
	 * @return integer Appointment end timestamp.
	 */
	public function get_end_cached(): ?int {
		return $this->end_cached;
	}

	/**
	 * Constructor for WC_Appointment.
	 *
	 * Initializes an appointment object. Can load an existing appointment by ID,
	 * or create a new appointment from an array of data. Handles data normalization,
	 * parent appointment inheritance, and order item relationships.
	 *
	 * @since 3.3.0
	 *
	 * @param int|array<string, mixed>|object $appointment Optional. Appointment ID, data array, or object. Default 0.
	 *
	 * @throws Exception If appointment cannot be loaded or created.
	 *
	 * @example
	 * // Load existing appointment
	 * $appointment = new WC_Appointment( 123 );
	 *
	 * // Create new appointment from array
	 * $appointment = new WC_Appointment( [
	 *     'product_id' => 456,
	 *     'start_date' => strtotime( 'tomorrow 14:00' ),
	 *     'end_date'   => strtotime( 'tomorrow 15:00' ),
	 *     'customer_id' => 789,
	 * ] );
	 */
	public function __construct( $appointment = 0 ) {
		parent::__construct( $appointment );

		if ( is_array( $appointment ) ) {
			$appointment['customer_id'] = $appointment['user_id'] ?? $appointment['customer_id'] ?? null;

			$appointment['start'] = $appointment['start_date'] ?? $appointment['start'] ?? null;
			$appointment['end'] = $appointment['end_date'] ?? $appointment['end'] ?? null;

			// Inherit data from parent.
			if ( ! empty( $appointment['parent_id'] ) ) {
				$parent_appointment = get_wc_appointment( $appointment['parent_id'] );

				if ( empty( $appointment['order_item_id'] ) ) {
					$appointment['order_item_id'] = $parent_appointment->data_store->get_appointment_order_item_id( $parent_appointment->get_id() );
				}
				if ( empty( $appointment['customer_id'] ) ) {
					$appointment['customer_id'] = $parent_appointment->data_store->get_appointment_customer_id( $parent_appointment->get_id() );
				}
			}

			// Get order ID from order item
			if ( ! empty( $appointment['order_item_id'] ) ) {
				if ( function_exists( 'wc_get_order_id_by_order_item_id' ) ) {
					$appointment['order_id'] = wc_get_order_id_by_order_item_id( $appointment['order_item_id'] );
				} else {
					global $wpdb;
					$appointment['order_id'] = (int) $wpdb->get_var(
						$wpdb->prepare(
							"SELECT order_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_item_id = %d",
							$appointment['order_item_id']
						)
					);
				}
			}

			// Get user ID.
			if ( empty( $appointment['customer_id'] ) && is_user_logged_in() && ! is_admin() ) {
				$appointment['customer_id'] = get_current_user_id();
			}

			// Setup the required data for the current user
			if ( empty($appointment['user_id']) && (is_user_logged_in() && ! is_admin()) ) {
				$appointment['user_id'] = get_current_user_id();
			}

			$this->set_props( $appointment );
			$this->set_object_read( true );
		} elseif ( is_numeric( $appointment ) && $appointment > 0 ) {
			$this->set_id( $appointment );
		} elseif ( $appointment instanceof self ) {
			$this->set_id( $appointment->get_id() );
		} elseif ( ! empty( $appointment->ID ) ) {
			$this->set_id( $appointment->ID );
		} else {
			$this->set_object_read( true );
		}

		$this->data_store = WC_Data_Store::load( $this->data_store_name );

		if ( $this->get_id() > 0 ) {
			try {
				$this->data_store->read( $this );
			} catch ( Exception $e ) {
				// Log a message.
				wc_get_logger()->info( $e->getMessage() );

				// Throwing back for `remove_inactive_appointment_from_cart()`.
				throw $e;
			}
			// For existing appointment: avoid doing the transition (default unpaid to the actual state).
			$this->status_transitioned = false;
			// For existing appointment: avoid doing the transition.
			$this->staff_transitioned = false;
		}
	}

	/**
	 * Save appointment to database.
	 *
	 * Saves the appointment object to the database, creating a new record if it doesn't exist
	 * or updating an existing one. Handles status and staff transitions, triggers hooks,
	 * schedules events, and manages order completion for zero-total orders.
	 *
	 * @since 3.0.0
	 *
	 * @param bool $status_transition Optional. Whether to trigger status transition hooks. Default true.
	 * @param bool $staff_transition  Optional. Whether to trigger staff transition hooks. Default true.
	 *
	 * @return int Appointment ID on success.
	 *
	 * @throws Exception If save operation fails.
	 *
	 * @example
	 * // Save appointment with transitions
	 * $appointment->set_status( 'confirmed' );
	 * $appointment_id = $appointment->save();
	 *
	 * // Save without triggering transitions (for bulk operations)
	 * $appointment->save( false, false );
	 */
	public function save( bool $status_transition = true, bool $staff_transition = true ): int {
		// Capture object before saving.
		$get_changes = $this->get_changes();

		if ( $this->data_store ) {
			// Trigger action before saving to the DB. Allows you to adjust object props before save.
			do_action( 'woocommerce_before_' . $this->object_type . '_object_save', $this, $this->data_store, $get_changes );

			if ( $this->get_id() ) {
				$this->data_store->update( $this );
			} else {
				$this->data_store->create( $this );
			}
		}

		WC_Cache_Helper::get_transient_version( 'appointments', true );

		if ( $status_transition ) {
			$this->status_transition();
		}

		if ( $staff_transition ) {
			$this->staff_transition();
		}

		if ( $get_changes ) {
			$this->appointment_changes( $get_changes );
		}

		// Mark the confirmed appointments order as complete if the total is zero.
		$this->mark_confirmed_order_complete_when_total_zero();

		$this->schedule_events( $get_changes );

		if ( $this->data_store ) {
			// Trigger action after saving to the DB. Allows you to trigger actionsafter save.
			do_action( 'woocommerce_after_' . $this->object_type . '_object_save', $this, $this->data_store, $get_changes );
		}

		return $this->get_id();
	}

	/**
	 * Handle the status transition.
	 *
	 * Processes status changes and triggers appropriate hooks and handlers.
	 * Called automatically when appointment status changes during save().
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	protected function status_transition(): void {
		if ( $this->status_transitioned ) {
			$allowed_statuses = [
				'was-in-cart' => __( 'Was In Cart', 'woocommerce-appointments' ),
			];

			$allowed_statuses = array_unique(
				array_merge(
					$allowed_statuses,
					get_wc_appointment_statuses( null, true ),
					get_wc_appointment_statuses( 'user', true ),
					get_wc_appointment_statuses( 'cancel', true )
				)
			);

			$from = empty( $allowed_statuses[ $this->status_transitioned['from'] ] )
				? false
				: $allowed_statuses[ $this->status_transitioned['from'] ];

			$to = empty( $allowed_statuses[ $this->status_transitioned['to'] ] )
				? false
				: $allowed_statuses[ $this->status_transitioned['to'] ];

			if ( $from && $to ) {
				$this->status_transitioned_handler( $from, $to );
			}

			// This has ran, so reset status transition variable.
			$this->status_transitioned = false;
		}
	}

	/**
	 * Handle the staff transition.
	 *
	 * Processes staff assignment changes and triggers appropriate hooks and handlers.
	 * Called automatically when appointment staff changes during save().
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	protected function staff_transition(): void {
		if ( $this->staff_transitioned ) {
			$from = $this->staff_transitioned['from'];
			$to = $this->staff_transitioned['to'];

			if ( $from || $to ) {
				$this->staff_transitioned_handler( $from, $to );
			}

			// This has ran, so reset status transition variable.
			$this->staff_transitioned = false;
		}
	}

	/**
	 * Mark the confirmed appointments order as complete if the total is zero.
	 *
	 * @since x.x.x
	 */
	public function mark_confirmed_order_complete_when_total_zero(): void {
		$order = $this->get_order();

		// Return if order not found.
		if ( ! $order ) {
			return;
		}

		// Guard: skip when order has no line items yet.
		// Creating appointments via the admin modal saves the appointment before
		// adding products to the order. Completing an empty, zero-total order here
		// is premature and creates confusing duplicate notes. Defer completion
		// until items exist and totals are calculated.
		if ( count( $order->get_items() ) < 1 ) {
			return; // Early exit: no items attached to order yet.
		}

		// Guard: do nothing if order is already completed to avoid redundant notes.
		if ( $order->has_status( 'completed' ) ) {
			return;
		}

		// Guard: if any line item has a non-zero total (after discounts), the order is not free.
		// Note: We check line_total (after discounts), not line_subtotal (before discounts),
		// so that 100% discount coupons correctly result in order completion.
		foreach ( $order->get_items( 'line_item' ) as $item ) {
			$line_total = (float) $item->get_total();
			if ( 0.0 !== $line_total ) {
				return;
			}
		}

		$order_total = (float) $order->get_total();

		// Handle orders that have a zero total, like free orders.
		if ( 0.0 !== $order_total ) {
			return;
		}

		// Don't proceed and mark order as complete if any of the linked appointment are not confirmed yet.
		// if ( ! WC_Appointment_Data_Store::all_appointments_in_order_confirmed( $order->get_id() ) ) {
		// 	return;
		// }

		$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_from_order_id( $order->get_id() );

		// Don't proceed and mark order as complete if any of the linked appointments
		// are not confirmed yet.
		// Note: We don't check appointment cost here because the order total (which
		// accounts for coupons/discounts) is the source of truth for what's owed.
		foreach ( $appointment_ids as $appointmet_id ) {
			$appointmet = get_wc_appointment( $appointmet_id );
			if ( 'confirmed' !== $appointmet->get_status() ) {
				return;
			}
		}

		/**
		 * Filter to update the order status when all appointments are confirmed when total is zero.
		 *
		 * @param string 'completed' The order status.
		 * @param WC_Order $order
		 *
		 * @since x.x.x
		 */
		$status = apply_filters( 'woocommerce_appointments_zero_order_status', 'completed', $order );

		// Update order status now that we know it's a zero-total order with items
		// and all linked appointments are confirmed.
		$order->update_status( $status );
	}

	/**
	 * Skip status transition events.
	 *
	 * Allows self::status_transition to be bypassed before calling self::save().
	 *
	 * @since 3.0.0
	 * @version 3.0.0
	 */
	public function skip_status_transition_events(): void {
		$this->status_transitioned = false;
	}

	/**
	 * Check for duplicate notes and add only once.
	 *
	 * Prevents identical appointment status change notes from being added multiple times
	 * to the order (e.g., when multiple save paths trigger the same transition).
	 */

	/**
	 * Handler when appointment status is transitioned.
	 *
	 * @since 3.0.0
	 *
	 * @param string $from Status from.
	 * @param string $to   Status to.
	 */
	protected function status_transitioned_handler( string $from, string $to ): void {
		// Add note to related order.
		$order = $this->get_order();

		if ( $order ) {
			/* translators: %1$d: appointment id %2$s: old status %3$s: new status */
			$order->add_order_note( sprintf( __( 'Appointment #%1$d status changed from "%2$s" to "%3$s"', 'woocommerce-appointments' ), $this->get_id(), $from, $to ), false, true );
		}

		// Fire the events of valid status has been transitioned.
		/**
		 * Hook: woocommerce_appointment_{new_status}
		 *
		 * @since 3.0.0
		 *
		 * @param int            $appointment_id Appointment id.
		 * @param WC_Appointment $appointment    Appointment object.
		 */
		do_action( 'woocommerce_appointment_' . $this->status_transitioned['to'], $this->get_id(), $this );
		/**
		 * Hook: woocommerce_appointment_{old_status}_to_{new_status}
		 *
		 * @since 3.0.0
		 *
		 * @param int            $appointment_id Appointment id.
		 * @param WC_Appointment $appointment    Appointment object.
		 */
		do_action( 'woocommerce_appointment_' . $this->status_transitioned['from'] . '_to_' . $this->status_transitioned['to'], $this->get_id(), $this );

		/**
		 * Hook: woocommerce_appointment_status_changed
		 *
		 * @since 4.11.3
		 *
		 * @param string         $from           Previous status.
		 * @param string         $to             New (current) status.
		 * @param int            $appointment_id Appointment id.
		 * @param WC_Appointment $appointment    Appointment object.
		 */
		do_action( 'woocommerce_appointment_status_changed', $this->status_transitioned['from'], $this->status_transitioned['to'], $this->get_id(), $this );
	}

	/**
	 * Handler when appointment staff is changed.
	 *
	 * @since 4.22.0
	 *
	 * @param string|array $from Status from.
	 * @param string|array $to   Status to.
	 */
	protected function staff_transitioned_handler( $from, $to ): void { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
		// Add note to related order.
		$order = $this->get_order();

		if ( $order ) {
			$order->add_order_note(
				sprintf(
					/* translators: %1$d: appointment id %2$s: old staff %3$s: new staff */
					__( 'Appointment #%1$d staff changed from "%2$s" to "%3$s"', 'woocommerce-appointments' ),
					$this->get_id(),
					wc_appointments_get_staff_from_ids( $from, true ),
					wc_appointments_get_staff_from_ids( $to, true )
				),
				false,
				true
			);
		}

		// Make sure staff are arrays.
		$from = is_array( $from ) ? $from : [ $from ];
		$to   = is_array( $to ) ? $to : [ $to ];

		/**
		 * Hook: woocommerce_appointment_status_changed
		 *
		 * @since 4.22.0
		 *
		 * @param string         $from           Previous status.
		 * @param string         $to             New (current) status.
		 * @param int            $appointment_id Appointment id.
		 * @param WC_Appointment $appointment    Appointment object.
		 */
		do_action( 'woocommerce_appointment_staff_changed', $from, $to, $this->get_id(), $this );
	}

	/**
     * Handle the appointment changes.
     *
     * Processes changes to appointment properties and triggers appropriate actions.
     * Handles product ID changes, status updates, and schedules email events.
     *
     * @since 1.0.0
     *
     * @param array<string, mixed> $get_changes Optional. Array of changed properties. Default empty array.
     *
     * @return void
     */
    protected function appointment_changes( array $get_changes = [] ): void {
		// Get order object.
		$order = $this->get_order();

		// Loop through changes.
		foreach ( $get_changes as $change_key => $change_value ) {

			// Product ID has changed.
			if ( $order && 'product_id' === $change_key ) {
				// New product object.
				$product = wc_get_product( $change_value );

				// Go through all order items.
				if ( count( $order->get_items() ) > 0 ) {

					// Calculate totals again.
					foreach ( $order->get_items() as $order_item_id => $item ) {

						// Get appointment IDs from order item.
						$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_from_order_item_id( $order_item_id );

						if ( $appointment_ids && in_array( $this->get_id(), $appointment_ids ) ) {
							$line_item = new WC_Order_Item_Product( $order_item_id );

							// Update line item.
							$line_item->set_product( $product );
							$line_item->save();
						}
					}
				}
			}

			// Quantity has changed.
			if ( $order && 'qty' === $change_key ) {
				$new_qty = absint( $change_value );

				// Try direct linkage via stored order_item_id first.
				$order_item_id_for_appointment = $this->get_order_item_id( 'edit' );
				$updated                      = false;

				if ( $order_item_id_for_appointment > 0 ) {
					try {
						$line_item = new WC_Order_Item_Product( $order_item_id_for_appointment );
						if ( 'line_item' === $line_item['type'] ) {
							$line_item->set_quantity( $new_qty );
							// Update totals when no explicit appointment cost was set.
							$product = wc_get_product( $this->get_product_id( 'edit' ) );
							if ( $product ) {
								$cost        = (float) ( $this->get_cost( 'edit' ) ?: 0 );
								$product_price = (float) wc_format_decimal( $product->get_price() );
								$line_total    = $cost > 0 ? $cost : $product_price * $new_qty;
								$line_item->set_subtotal( $line_total );
								$line_item->set_total( $line_total );
							}
							$line_item->save();
							$updated = true;
						}
					} catch ( Exception $e ) {
						// Fallback to iterative search below.
					}
				}

				// Fallback: find linked item by scanning order items.
				if ( ! $updated && count( $order->get_items() ) > 0 ) {
					foreach ( $order->get_items() as $order_item_id => $item ) {
						$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_from_order_item_id( $order_item_id );
						if ( $appointment_ids && in_array( $this->get_id(), $appointment_ids ) ) {
							$line_item = new WC_Order_Item_Product( $order_item_id );
							if ( 'line_item' !== $line_item['type'] ) {
								continue;
							}
							$line_item->set_quantity( $new_qty );
							$product = wc_get_product( $this->get_product_id( 'edit' ) );
							if ( $product ) {
								$cost          = (float) ( $this->get_cost( 'edit' ) ?: 0 );
								$product_price = (float) wc_format_decimal( $product->get_price() );
								$line_total    = $cost > 0 ? $cost : $product_price * $new_qty;
								$line_item->set_subtotal( $line_total );
								$line_item->set_total( $line_total );
							}
							$line_item->save();
							$updated = true;
							break;
						}
					}
				}

				// Recalculate order totals when something changed.
				if ( $updated ) {
					$order->calculate_totals();
					$order->save();
				}
			}
		}
	}

	/*
	|--------------------------------------------------------------------------
	| CRUD Getters and setters.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Get whether the appointment is all-day.
	 *
	 * Returns whether the appointment spans the entire day without specific time slots.
	 *
	 * @since 3.0.0
	 *
	 * @param string $context Optional. Context for retrieving the value ('view' or 'edit'). Default 'view'.
	 *
	 * @return bool True if all-day appointment, false otherwise.
	 *
	 * @example
	 * // Check if appointment is all-day
	 * if ( $appointment->get_all_day() ) {
	 *     echo 'All-day appointment';
	 * }
	 */
	public function get_all_day( string $context = 'view' ): bool {
		return $this->get_prop( 'all_day', $context );
	}

	/**
	 * Set all_day.
	 *
	 * @param boolean $value
	 */
	public function set_all_day( $value ): void {
		$this->set_prop( 'all_day', wc_appointments_string_to_bool( $value ) );
	}

	/**
	 * Get appointment cost.
	 *
	 * Returns the cost/price of the appointment.
	 *
	 * @since 3.0.0
	 *
	 * @param string $context Optional. Context for retrieving the value ('view' or 'edit'). Default 'view'.
	 *
	 * @return float Appointment cost.
	 *
	 * @example
	 * // Get appointment cost
	 * $cost = $appointment->get_cost();
	 * echo wc_price( $cost );
	 */
	public function get_cost( string $context = 'view' ): float {
		$cost = $this->get_prop( 'cost', $context );
		
		// Normalize to float - handle string, numeric, or float values
		if ( is_numeric( $cost ) ) {
			return (float) $cost;
		}
		
		if ( is_string( $cost ) && '' !== $cost ) {
			$float_value = (float) $cost;
			// Only return if conversion was successful (not NaN)
			if ( is_finite( $float_value ) ) {
				return $float_value;
			}
		}
		
		return 0.0;
	}

	/**
	 * Set cost.
	 *
	 * @param float $value
	 */
	public function set_cost( $value ): void {
		$this->set_prop( 'cost', wc_format_decimal( $value ) );
	}

	/**
	 * Returns the WordPress user ID of the customer who made this appointment.
	 * Returns 0 for guest appointments.
	 *
	 * @since 1.0.0
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Customer user ID, or 0 if guest appointment.
	 */
	public function get_customer_id( string $context = 'view' ): int {
		return (int) $this->get_prop( 'customer_id', $context );
	}

	/**
	 * Set customer_id.
	 *
	 * @param integer $value
	 */
	public function set_customer_id( $value ): void {
		$new_customer_id = absint( $value );

		// Add customer ID, when creating new account.
		if ( 0 === $new_customer_id ) {
			$customer        = $this->get_customer();
			$new_customer_id = $customer->user_id ?? $new_customer_id;
		}

		$this->set_prop( 'customer_id', $new_customer_id );
	}

	/**
	 * Returns the date the appointment was created.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Timestamp of creation date.
	 */
	public function get_date_created( string $context = 'view' ): int {
		return (int) $this->get_prop( 'date_created', $context );
	}

	/**
	 * Set date_created.
	 *
	 * @param string|int $timestamp Timestamp
	 * @throws WC_Data_Exception
	 */
	public function set_date_created( $timestamp ): void {
		$this->set_prop( 'date_created', is_numeric( $timestamp ) ? $timestamp : strtotime( $timestamp ) );
	}

	/**
	 * Returns the date the appointment was last modified.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Timestamp of last modification date.
	 */
	public function get_date_modified( string $context = 'view' ): int {
		return (int) $this->get_prop( 'date_modified', $context );
	}

	/**
	 * Set date_modified.
	 *
	 * @param string|int $timestamp
	 * @throws WC_Data_Exception
	 */
	public function set_date_modified( $timestamp ): void {
		$this->set_prop( 'date_modified', is_numeric( $timestamp ) ? $timestamp : strtotime( $timestamp ) );
	}

	/**
	 * Returns the appointment end timestamp.
	 *
	 * @param string $context    Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 * @param string $deprecated Deprecated param.
	 *
	 * @return int Timestamp of end time.
	 */
	/**
	 * Get appointment end timestamp.
	 *
	 * TIMEZONE NOTE:
	 * ==============
	 * Returns a UTC Unix timestamp (seconds since epoch) that represents
	 * the end time in the site's configured timezone.
	 *
	 * Important:
	 * - The timestamp is UTC but semantically represents site timezone time
	 * - When extracting components, use local methods (date(), format()) not UTC methods
	 * - Do NOT perform timezone conversion - the timestamp already represents the correct time
	 *
	 * Example:
	 * If site timezone is "Europe/Ljubljana" (UTC+1) and appointment ends at 15:00:
	 * - Timestamp represents 15:00 in Ljubljana time
	 * - When extracted with date('H:i', $timestamp), it shows 15:00
	 * - Do NOT use UTC methods or timezone conversion
	 *
	 * See TIMEZONE_ARCHITECTURE.md for detailed explanation.
	 *
	 * @param string $context   Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 * @param mixed  $deprecated Optional. Deprecated parameter. Default empty.
	 *
	 * @return int End timestamp (UTC, but represents site timezone time).
	 */
	public function get_end( string $context = 'view', $deprecated = '' ): int {
		$end = (int) $this->get_prop( 'end', $context );

		return $this->is_all_day() ? strtotime( 'midnight +1 day -1 second', $end ) : $end;
	}

	/**
	 * Set end_time.
	 *
	 * TIMEZONE NOTE:
	 * ==============
	 * Accepts a UTC Unix timestamp that represents the end time in the site's
	 * configured timezone. The timestamp should already be converted from customer
	 * timezone to site timezone (if applicable) before calling this method.
	 *
	 * @param string|int $timestamp UTC timestamp representing site timezone time.
	 * @throws WC_Data_Exception
	 */
	public function set_end( $timestamp ): void {
		$this->end_cached = null;
		$this->set_prop( 'end', is_numeric( $timestamp ) ? $timestamp : strtotime( $timestamp ) );
	}

	/**
	 * Returns the Google Calendar event ID associated with this appointment.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return string Google Calendar event ID.
	 */
	public function get_google_calendar_event_id( string $context = 'view' ): string {
		return $this->get_prop( 'google_calendar_event_id', $context );
	}

	/**
     * Set google_calendar_event_id
     */
    public function set_google_calendar_event_id( string $value ): void {
		$this->set_prop( 'google_calendar_event_id', $value );
	}

	/**
	 * Returns the Google Calendar event IDs for staff members.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return array Google Calendar event IDs keyed by staff ID.
	 */
	public function get_google_calendar_staff_event_ids( string $context = 'view' ): array {
		$value = $this->get_prop( 'google_calendar_staff_event_ids', $context );

		if ( is_array( $value ) ) {
			return $value;
		}

		if ( '' === $value || null === $value ) {
			return [];
		}

		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			if ( is_array( $decoded ) ) {
				return $decoded;
			}
		}

		return [];
	}

	/**
     * Set google_calendar_staff_event_ids
     */
    public function set_google_calendar_staff_event_ids( $value ): void {
		if ( is_array( $value ) ) {
			$this->set_prop( 'google_calendar_staff_event_ids', $value );
			return;
		}

		if ( '' === $value || null === $value ) {
			$this->set_prop( 'google_calendar_staff_event_ids', [] );
			return;
		}

		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			if ( is_array( $decoded ) ) {
				$this->set_prop( 'google_calendar_staff_event_ids', $decoded );
				return;
			}
		}

		$this->set_prop( 'google_calendar_staff_event_ids', [] );
	}

	/**
	 * Returns the order ID associated with this appointment.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Order ID.
	 */
	public function get_order_id( string $context = 'view' ): int {
		return (int) $this->get_prop( 'order_id', $context );
	}

	/**
	 * Set order_id
	 *
	 * @param  int $value
	 */
	public function set_order_id( $value ): void {
		$this->set_prop( 'order_id', absint( $value ) );
	}

	/**
	 * Returns the order item ID associated with this appointment.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Order item ID.
	 */
	public function get_order_item_id( string $context = 'view' ): int {
		return (int) $this->get_prop( 'order_item_id', $context );
	}

	/**
	 * Set order_item_id.
	 *
	 * @param integer $value
	 */
	public function set_order_item_id( $value ): void {
		$this->set_prop( 'order_item_id', absint( $value ) );
	}

	/**
	 * Returns the parent appointment ID if this is a child appointment.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Parent appointment ID.
	 */
	public function get_parent_id( string $context = 'view' ): int {
		return (int) $this->get_prop( 'parent_id', $context );
	}

	/**
	 * Set parent ID.
	 *
	 * @param  int $value
	 */
	public function set_parent_id( $value ): void {
		$this->set_prop( 'parent_id', absint( $value ) );
	}

	/**
	 * Returns the product ID associated with this appointment.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Product ID.
	 */
	public function get_product_id( string $context = 'view' ): int {
		return (int) $this->get_prop( 'product_id', $context );
	}

	/**
	 * Set product_id.
	 *
	 * @param integer $value
	 */
	public function set_product_id( $value ): void {
		$this->set_prop( 'product_id', absint( $value ) );
	}

	/**
	 * Returns the list of staff IDs assigned to this appointment.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return array Array of staff IDs.
	 */
	public function get_staff_ids( string $context = 'view' ): array {
		$staff_ids = $this->get_prop( 'staff_ids', $context );
		
		// Normalize to array - handle string, numeric, or array values
		if ( is_array( $staff_ids ) ) {
			return $staff_ids;
		}
		
		if ( is_numeric( $staff_ids ) && $staff_ids > 0 ) {
			return [ (int) $staff_ids ];
		}
		
		if ( is_string( $staff_ids ) && '' !== $staff_ids ) {
			// Try to parse comma-separated string
			$parsed = array_filter( array_map( 'intval', explode( ',', $staff_ids ) ) );
			if ( $parsed !== [] ) {
				return $parsed;
			}
		}
		
		return [];
	}

	/**
	 * Set staff_ids (from admin page).
	 *
	 * Sets the staff member IDs assigned to this appointment.
	 * Can accept a single staff ID or an array of staff IDs.
	 *
	 * @since 1.0.0
	 *
	 * @param string|array<int> $new_staff_ids Staff ID(s) to assign. Can be single ID or array of IDs.
	 *
	 * @return array<string, mixed> Details of the change including 'from' and 'to' staff IDs.
	 */
	public function set_staff_ids( $new_staff_ids ): array { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
		$old_staff_ids = $this->get_staff_ids();
		// Each 'staff_id' is saved in appointment data store
		$this->set_prop( 'staff_ids', $new_staff_ids );

		// Fire up only on staff change and existing appointment.
		if ( $new_staff_ids !== $old_staff_ids ) {
			$this->staff_transitioned = [
				'from' => $old_staff_ids,
				'to'   => $new_staff_ids,
			];
		}

		return [
			'from' => $old_staff_ids,
			'to'   => $new_staff_ids,
		];
	}

	/**
	 * Get the primary staff ID for this appointment (convenience method).
	 *
	 * Returns the first staff ID from the staff_ids array. This is a backwards-compatible
	 * convenience method for cases where only a single staff member is expected.
	 *
	 * IMPORTANT: Appointments can have MULTIPLE staff members assigned. For multi-staff
	 * scenarios, always use get_staff_ids() instead. This method only returns the first
	 * staff member and should only be used when you know the appointment has a single staff.
	 *
	 * @since 4.0.0
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int The first staff ID, or 0 if no staff is assigned.
	 *
	 * @see get_staff_ids() For retrieving all assigned staff members.
	 *
	 * @example
	 * // For single-staff appointments
	 * $staff_id = $appointment->get_staff_id();
	 *
	 * // For multi-staff appointments, use get_staff_ids() instead
	 * $staff_ids = $appointment->get_staff_ids();
	 */
	public function get_staff_id( string $context = 'view' ): int {
		$staff_ids = $this->get_staff_ids( $context );
		return ! empty( $staff_ids ) ? (int) reset( $staff_ids ) : 0;
	}

	/**
     * Set staff_id (from front page).
     *
     * Sets a single staff member ID for this appointment.
     * Used when booking from the frontend. Internally stores in staff_ids.
     *
     * IMPORTANT: This method stores the staff ID in the staff_ids property (not staff_id).
     * For setting multiple staff members, use set_staff_ids() with an array.
     *
     * @since 1.0.0
     *
     * @param string|int $new_staff_id Staff ID to assign.
     *
     * @return void
     *
     * @see set_staff_ids() For setting multiple staff members.
     */
    public function set_staff_id( $new_staff_id ): void {
		// Staff is stored in the 'staff_ids' property (supports multiple staff)
		$this->set_prop( 'staff_ids', $new_staff_id );
	}

	/**
	 * Returns the appointment start timestamp.
	 *
	 * @param string $context    Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 * @param string $deprecated Deprecated param.
	 *
	 * @return int Timestamp of start time.
	 */
	/**
	 * Get appointment start timestamp.
	 *
	 * TIMEZONE NOTE:
	 * ==============
	 * Returns a UTC Unix timestamp (seconds since epoch) that represents
	 * the start time in the site's configured timezone.
	 *
	 * Important:
	 * - The timestamp is UTC but semantically represents site timezone time
	 * - When extracting components, use local methods (date(), format()) not UTC methods
	 * - Do NOT perform timezone conversion - the timestamp already represents the correct time
	 *
	 * Example:
	 * If site timezone is "Europe/Ljubljana" (UTC+1) and appointment starts at 14:00:
	 * - Timestamp represents 14:00 in Ljubljana time
	 * - When extracted with date('H:i', $timestamp), it shows 14:00
	 * - Do NOT use UTC methods or timezone conversion
	 *
	 * See TIMEZONE_ARCHITECTURE.md for detailed explanation.
	 *
	 * @param string $context   Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 * @param mixed  $deprecated Optional. Deprecated parameter. Default empty.
	 *
	 * @return int Start timestamp (UTC, but represents site timezone time).
	 */
	public function get_start( string $context = 'view', $deprecated = '' ): int {
		$start = (int) $this->get_prop( 'start', $context );

		return $this->is_all_day() ? strtotime( 'midnight', $start ) : $start;
	}

	/**
	 * Set start_time.
	 *
	 * TIMEZONE NOTE:
	 * ==============
	 * Accepts a UTC Unix timestamp that represents the start time in the site's
	 * configured timezone. The timestamp should already be converted from customer
	 * timezone to site timezone (if applicable) before calling this method.
	 *
	 * @param string|int $timestamp UTC timestamp representing site timezone time.
	 * @throws WC_Data_Exception
	 */
	public function set_start( $timestamp ): void {
		$new_start = is_numeric( $timestamp ) ? $timestamp : strtotime( $timestamp );

		$this->start_cached = null;

		$this->set_prop( 'start', $new_start );
	}

	/**
	 * Returns the appointment status.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return string Appointment status.
	 */
	public function get_status( string $context = 'view' ): string {
		return $this->get_prop( 'status', $context );
	}

	/**
	 * Set status.
	 *
	 * Sets the appointment status. No internal wc- prefix is required.
	 * Triggers status transition handlers and hooks.
	 *
	 * @since 1.0.0
	 *
	 * @param string $new_status Status to change the appointment to. No internal wc- prefix is required.
	 *
	 * @return array<string, mixed> Details of the change including 'from' and 'to' status values.
	 */
	public function set_status( string $new_status ): array {
		$old_status = $this->get_status();

		$this->set_prop( 'status', $new_status );

		if ( $new_status !== $old_status ) {
			$this->status_transitioned = [
				'from' => $old_status,
				'to'   => $new_status,
			];
		}

		return [
			'from' => $old_status,
			'to'   => $new_status,
		];
	}

	/**
	 * Returns the customer status (e.g., expected, arrived, no-show).
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return string Customer status.
	 */
	public function get_customer_status( string $context = 'view' ): string {
		return $this->get_prop( 'customer_status', $context );
	}

	/**
     * Set customer_status.
     *
     * Sets the customer status (expected, arrived, no-show).
     * Automatically sets to 'no-show' if appointment is past and customer status is 'expected'.
     *
     * @since 1.0.0
     *
     * @param string $new_customer_status Customer status to set ('expected', 'arrived', or 'no-show').
     *
     * @return void
     */
    public function set_customer_status( string $new_customer_status ): void {
		// Set status to "no-show", when appointment is past current time, is not paid and customer status is set to "expected".
		if ( 'expected' === $this->get_customer_status()
		    && ! $this->has_status( [ 'paid', 'complete' ] )
		    && $this->get_start() < current_time( 'timestamp' )
		    && $this->get_end() < current_time( 'timestamp' )
		) {
			$new_customer_status = 'no-show';
		}

		$this->set_prop( 'customer_status', $new_customer_status );
	}

	/**
	 * Returns the quantity (number of people/spots) for this appointment.
	 *
	 * @param string $context Optional. Context for retrieving data ('view' or 'edit'). Default 'view'.
	 *
	 * @return int Quantity.
	 */
	public function get_qty( string $context = 'view' ): int {
		return (int) $this->get_prop( 'qty', $context );
	}

	/**
     * Set qty.
     *
     * Sets the quantity (number of people/spots) for this appointment.
     *
     * @since 1.0.0
     *
     * @param int $new_qty Quantity to set (will be converted to absolute integer).
     *
     * @return void
     */
    public function set_qty( $new_qty ): void {
		$this->set_prop( 'qty', absint( $new_qty ) );
	}

	/**
     * Return the timezone without wc- internal prefix.
     */
    public function get_timezone( string $context = 'view' ): string {
		return $this->get_prop( 'timezone', $context );
	}

	/**
     * Set timezone.
     *
     * Sets the timezone for this appointment (e.g., 'America/New_York' or 'UTC+5').
     *
     * @since 1.0.0
     *
     * @param string $new_timezone Timezone string to set.
     *
     * @return void
     */
    public function set_timezone( string $new_timezone ): void {
		$this->set_prop( 'timezone', $new_timezone );
	}

	/**
     * Get local_timezone.
     */
    public function get_local_timezone( string $context = 'view' ): string {
		return $this->get_prop( 'local_timezone', $context );
	}

	/**
	 * Set local_timezone.
	 *
	 * @param string $timezone Local timezone string.
	 *
	 * @throws WC_Data_Exception If data is invalid.
	 */
	public function set_local_timezone( string $timezone ): void {
		$this->set_prop( 'local_timezone', $timezone );
	}

	/*
    |--------------------------------------------------------------------------
    | Conditonals
    |--------------------------------------------------------------------------
    */
    /**
     * Checks the appointment status against a passed in status.
     *
     * @param string|array<string> $status Status or array of statuses to check against.
     *
     * @return bool True if appointment has the specified status (or one of the statuses if array), false otherwise.
     */
    public function has_status( $status ): bool {
		return apply_filters( 'woocommerce_appointment_has_status', ( is_array( $status ) && in_array( $this->get_status(), $status ) ) || $this->get_status() === $status, $this, $status );
	}

	/**
     * Return if all day event.
     *
     * Checks if this appointment is an all-day appointment (no specific time).
     *
     * @since 1.0.0
     *
     * @return bool True if appointment is all-day, false otherwise.
     */
    public function is_all_day(): bool {
		return $this->get_all_day();
	}

	/**
     * See if this appointment is within a slot.
     *
     * Checks if the appointment is completely contained within the given time slot.
     * The appointment must start on or after slot_start and end on or before slot_end.
     *
     * @since 1.0.0
     *
     * @param int $slot_start Slot start timestamp.
     * @param int $slot_end   Slot end timestamp.
     *
     * @return bool True if appointment is completely within the slot, false otherwise.
     */
    public function is_within_slot( int $slot_start, int $slot_end ): bool {
		// Cache start/end to speed up repeated calls.
		if ( null === $this->start_cached ) {
			$this->start_cached = $this->get_start();
		}
		if ( null === $this->end_cached ) {
			$this->end_cached = $this->get_end();
		}
		$start = $this->start_cached;
		$end   = $this->end_cached;
        return !(! $start || ! $end || $start >= $slot_end || $end <= $slot_start);

		/*
		// Condition: Already Booked range must be inside (or equals to) the slot range.
		if ( ! $start || ! $end || ! ( $slot_start <= $start && $end <= $slot_end ) ) {
			return false;
		}

		return true;
		*/
	}

	/**
     * See if the appointment intersects a slot.
     *
     * Checks if the appointment overlaps with the given time slot in any way.
     * Returns true if there is any time overlap between the appointment and the slot.
     *
     * @since 1.0.0
     *
     * @param int $slot_start Slot start timestamp.
     * @param int $slot_end   Slot end timestamp.
     *
     * @return bool True if appointment intersects (overlaps) with the slot, false otherwise.
     */
    public function is_intersecting_slot( int $slot_start, int $slot_end ): bool {
		// Cache start/end to speed up repeated calls.
		if ( null === $this->start_cached ) {
			$this->start_cached = $this->get_start();
		}
		if ( null === $this->end_cached ) {
			$this->end_cached = $this->get_end();
		}
		$start = $this->start_cached;
		$end   = $this->end_cached;
        // Condition: Already Booked range must intersect the slot range.
        return !(! $start || ! $end || $end <= $slot_start || $slot_end <= $start);
	}

	/**
     * See if this appointment can still be cancelled by the user or not.
     *
     * Checks if the cancellation deadline has passed based on the product's cancel limit.
     * Returns true if cancellation is no longer allowed (deadline passed or product doesn't allow cancellation).
     *
     * @since 1.0.0
     *
     * @return bool True if cancellation deadline has passed (cannot cancel), false if still can cancel.
     */
    public function passed_cancel_day(): bool {
		$product = $this->get_product();

		if ( ! $product || ! $product->can_be_cancelled() ) {
			return true;
		}

		if ( false !== $product ) {
			$cancel_limit      = $product->get_cancel_limit();
			$cancel_limit_unit = $cancel_limit > 1 ? $product->get_cancel_limit_unit() . 's' : $product->get_cancel_limit_unit();
			$cancel_string     = sprintf( '%s +%d %s', current_time( 'd F Y H:i:s' ), $cancel_limit, $cancel_limit_unit );

			if ( strtotime( $cancel_string ) >= $this->get_start() ) {
				return true;
			}
		}

		return false;
	}

	/**
     * See if this appointment can still be rescheduled by the user or not.
     *
     * Checks if the reschedule deadline has passed based on the product's reschedule limit.
     * Returns true if rescheduling is no longer allowed (deadline passed or product doesn't allow rescheduling).
     *
     * @since 1.0.0
     *
     * @return bool True if reschedule deadline has passed (cannot reschedule), false if still can reschedule.
     */
    public function passed_reschedule_day(): bool {
		$product = $this->get_product();

		if ( ! $product || ! $product->can_be_rescheduled() ) {
			return true;
		}

		if ( false !== $product ) {
			$reschedule_limit      = $product->get_reschedule_limit();
			$reschedule_limit_unit = $reschedule_limit > 1 ? $product->get_reschedule_limit_unit() . 's' : $product->get_reschedule_limit_unit();
			$reschedule_string     = sprintf( '%s +%d %s', current_time( 'd F Y H:i:s' ), $reschedule_limit, $reschedule_limit_unit );

			if ( strtotime( $reschedule_string ) >= $this->get_start() ) {
				return true;
			}
		}

		return false;
	}

	/**
     * Returns if staff are enabled/needed for the appointment product.
     *
     * Checks if the appointment's product requires staff assignment.
     *
     * @since 1.0.0
     *
     * @return bool True if product has staff enabled, false otherwise.
     */
    public function has_staff(): bool {
		return $this->get_product() && $this->get_product()->has_staff();
	}

	/**
     * Get the staff members for this appointment.
     *
     * Retrieves staff member objects, names, or HTML links based on the appointment's staff IDs.
     * Returns false if no staff is assigned to this appointment.
     *
     * @since 1.0.0
     *
     * @param bool $names     Optional. If true, returns comma-separated string of names. Default false.
     * @param bool $with_link Optional. If true, returns HTML links. Default false.
     *
     * @return array<int, WC_Product_Appointment_Staff>|string|false {
     *     Return format depends on parameters:
     *     - If $names is true: comma-separated string of staff names.
     *     - If $with_link is true: array of HTML anchor tags.
     *     - Otherwise: array of WC_Product_Appointment_Staff objects.
     *     - If no staff assigned: false.
     * }
     */
    public function get_staff_members( bool $names = false, bool $with_link = false ) { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
		$ids = $this->get_staff_ids();

		if ( $ids === [] ) {
			return false;
		}

		return wc_appointments_get_staff_from_ids( $ids, $names, $with_link );
	}

	/*
    |--------------------------------------------------------------------------
    | Non-CRUD getters/helpers.
    |--------------------------------------------------------------------------
    */
    /**
     * Returns appointment start date.
     *
     * Formats the appointment start timestamp according to the specified date and time formats.
     * Handles timezone conversions for customer-facing views and all-day appointments.
     *
     * @since 1.0.0
     *
     * @param string|null $date_format Optional. PHP date format string. Default null (uses site setting).
     * @param string|null $time_format Optional. PHP time format string. Default null (uses site setting).
     *
     * @return string|false Formatted date string, or false if start time is not set (0).
     */
    public function get_start_date( ?string $date_format = null, ?string $time_format = null ) {
		if ( $this->get_start() !== 0 ) {
			if ( is_null( $date_format ) ) {
				$date_format = wc_appointments_date_format();
			}
			if ( is_null( $time_format ) ) {
				$time_format = ', ' . wc_appointments_time_format();
			}
			if ( $this->is_all_day() ) {
				return date_i18n( $date_format, $this->get_start() );
			}
            #echo current_filter();
            // Customer's timezone viewpoints.
            $customers_viewpoints = [
					'woocommerce_order_item_meta_start',
					'woocommerce_order_item_meta_end',
					'woocommerce_account_appointments_endpoint',
					'woocommerce_appointment_pending-confirmation_to_cancelled_notification',
					'woocommerce_appointment_confirmed_to_cancelled_notification',
					'woocommerce_appointment_paid_to_cancelled_notification',
					'wc-appointment-confirmed',
					'wc-appointment-reminder',
					'wc-appointment-follow-up',
					'fue_before_variable_replacements', #follow ups
				];
            // Timezone caluclation.
            if ( $this->get_local_timezone() && $this->get_product_has_timezones() && in_array( current_filter(), $customers_viewpoints ) ) {
					$start_date     = wc_appointment_timezone_locale( 'site', 'user', $this->get_start(), 'U', $this->get_local_timezone() );
					$get_start_date = date_i18n( $date_format . $time_format, $start_date ) . ' (' . wc_appointment_get_timezone_name( $this->get_local_timezone() ) . ')';
				} else {
					$get_start_date = date_i18n( $date_format . $time_format, $this->get_start() );
				}
            return apply_filters( 'woocommerce_appointments_get_start_date_with_time', $get_start_date, $this, $this->get_start() );
		}
		return false;
	}

	/**
     * Returns appointment end date.
     *
     * Formats the appointment end timestamp according to the specified date and time formats.
     * Handles timezone conversions for customer-facing views and all-day appointments.
     *
     * @since 1.0.0
     *
     * @param string|null $date_format Optional. PHP date format string. Default null (uses site setting).
     * @param string|null $time_format Optional. PHP time format string. Default null (uses site setting).
     *
     * @return string|false Formatted date string, or false if end time is not set (0).
     */
    public function get_end_date( ?string $date_format = null, ?string $time_format = null ) {
		if ( $this->get_end() !== 0 ) {
			if ( is_null( $date_format ) ) {
				$date_format = wc_appointments_date_format();
			}
			if ( is_null( $time_format ) ) {
				$time_format = ', ' . wc_appointments_time_format();
			}
			if ( $this->is_all_day() ) {
				return date_i18n( $date_format, $this->get_end() );
			}
            #echo current_filter();
            // Customer's timezone viewpoints.
            $customers_viewpoints = [
					'woocommerce_order_item_meta_start',
					'woocommerce_order_item_meta_end',
					'woocommerce_account_appointments_endpoint',
					'woocommerce_appointment_pending-confirmation_to_cancelled_notification',
					'woocommerce_appointment_confirmed_to_cancelled_notification',
					'woocommerce_appointment_paid_to_cancelled_notification',
					'wc-appointment-confirmed',
					'wc-appointment-reminder',
					'wc-appointment-follow-up',
					'fue_before_variable_replacements', #follow ups
				];
            // Timezone caluclation.
            if ( $this->get_local_timezone() && $this->get_product_has_timezones() && in_array( current_filter(), $customers_viewpoints ) ) {
					$end_date     = wc_appointment_timezone_locale( 'site', 'user', $this->get_end(), 'U', $this->get_local_timezone() );
					$get_end_date = date_i18n( $date_format . $time_format, $end_date ) . ' (' . wc_appointment_get_timezone_name( $this->get_local_timezone() ) . ')';
				} else {
					$get_end_date = date_i18n( $date_format . $time_format, $this->get_end() );
				}
            return apply_filters( 'woocommerce_appointments_get_end_date_with_time', $get_end_date, $this, $this->get_end() );
		}
		return false;
	}

	/**
     * Returns appointment duration.
     *
     * Calculates and formats the duration between start and end times.
     * Can return a pretty formatted string (e.g., "2 hours 30 minutes") or minutes as integer string.
     *
     * @since 1.0.0
     *
     * @param bool $pretty Optional. Whether to return formatted string. Default true.
     *
     * @return string Duration as formatted string (if $pretty is true) or minutes as string (if false).
     */
    public function get_duration( bool $pretty = true ): string {
		$minutes = WC_Appointment_Duration::calculate_minutes( $this->get_start(), $this->get_end() );
		if ( $pretty ) {
			return WC_Appointment_Duration::format_minutes( $minutes, $this->get_duration_unit() );
		}
		return (string) $minutes;
	}

	/**
     * Returns appointment duration unit.
     *
     * Gets the duration unit (minute, hour, day, month) from the associated product.
     * Falls back to 'minute' if product is not available.
     *
     * @since 1.0.0
     *
     * @param bool $pretty Optional. Unused parameter (kept for backward compatibility). Default true.
     *
     * @return string Duration unit constant (one of: 'minute', 'hour', 'day', 'month').
     */
    public function get_duration_unit( bool $pretty = true ): string {
		try {
			if ( $this->get_product() ) {
				return $this->get_product()->get_duration_unit();
			}
		} catch ( Exception $e ) {
			return WC_Appointments_Constants::DURATION_MINUTE;
		}
	}

	/**
	 * Returns appointment duration parameters.
	 *
	 * Calculates duration and duration_unit from the appointment's start and end times.
	 * Returns an array with the duration value and appropriate unit.
	 *
	 * @since 1.0.0
	 *
	 * @return array{
	 *     duration: int,
	 *     duration_unit: string
	 * } Duration parameters array with 'duration' (int) and 'duration_unit' (string) keys.
	 */
	public function get_duration_parameters(): array {
		$duration_in_minutes = WC_Appointment_Duration::calculate_minutes( $this->get_start(), $this->get_end() );
		$duration = WC_Appointment_Duration::from_minutes( $duration_in_minutes );
		return $duration->to_array();
	}

	/**
     * Returns appointment addons.
     *
     * Retrieves and formats product addons associated with this appointment.
     * Checks cart first (for in-cart appointments), then order, then returns false if none found.
     *
     * @since 1.0.0
     *
     * @param array<string, mixed> $args Optional. Formatting arguments. {
     *     @type string $before    HTML before each addon. Default '<ul class="wc-item-meta"><li>'.
     *     @type string $after     HTML after each addon. Default '</li></ul>'.
     *     @type string $separator HTML between addons. Default '</li><li>'.
     *     @type bool   $label     Whether to show labels. Default true.
     *     @type bool   $echo      Whether to echo output. Default false.
     *     @type bool   $autop     Whether to auto-format paragraphs. Default true.
     * }
     *
     * @return string|false HTML formatted addon fields, or false if no addons found.
     */
    public function get_addons( array $args = [] ) {
		$args = wp_parse_args(
			$args,
			[
				'before'    => '<ul class="wc-item-meta"><li>',
				'after'     => '</li></ul>',
				'separator' => '</li><li>',
				'label'     => true,
				'echo'      => false,
				'autop'     => true,
			]
		);

		if ( $this->has_status( [ 'was-in-cart', 'in-cart' ] ) ) {
			$addons_from_cart = $this->get_addons_from_cart( $args );

			if ( $addons_from_cart ) {
				return apply_filters( 'woocommerce_appointments_get_addons', $addons_from_cart, $this, $this->get_product() );
			}
		}

		$addons_from_order = $this->get_addons_from_order( $args );

		if ( $addons_from_order ) {
			return apply_filters( 'woocommerce_appointments_get_addons', $addons_from_order, $this, $this->get_product() );
		}

		return apply_filters( 'woocommerce_appointments_get_addons', false, $this, $this->get_product() );
	}

	/**
	 * Returns appointment addons from order.
	 *
	 * Retrieves and formats product addons from the associated order item.
	 * Only works if appointment is linked to an order.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $args Optional. Formatting arguments (same as get_addons()). Default empty array.
	 *
	 * @return string HTML formatted addon fields, or empty string if no addons or no order.
	 */
	public function get_addons_from_order( $args = [] ) {
		$order = $this->get_order();
		if ( $order ) {
			$item_meta = '';

			if ( count( $order->get_items( 'line_item' ) ) > 0 ) {
				foreach ( $order->get_items() as $item_id => $item ) {
					$product         = $item->get_product();
					$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_from_order_item_id( $item_id );

					if ( $appointment_ids
					    && $product
					    && is_wc_appointment_product( $product )
					    && $this->get_product_id() === $product->get_id()
					    && in_array( $this->get_id(), $appointment_ids )
				    ) {
						// Simpler, self-contained render: build formatted meta excluding any public 'appointment_id'.
						$formatted_meta = $item->get_formatted_meta_data();
						if ( ! empty( $formatted_meta ) ) {
							$strings = [];
							foreach ( $formatted_meta as $meta ) {
								$key = ( isset( $meta->display_key ) && '' !== $meta->display_key ) ? $meta->display_key : ( $meta->key ?? '' );
								$lower = is_string( $key ) ? strtolower( $key ) : '';
								$normalized = str_replace( [ ' ', '-' ], '_', $lower );
                                // Skip Appointment ID meta entirely.
                                if ('appointment_id' === $lower) {
                                    continue;
                                }
                                if ('appointment_id' === $normalized) {
                                    continue;
                                }
								$value = $meta->display_value ?? ( $meta->value ?? '' );
								// Allow zero-like values but skip empty ones.
								if ( '' === $value ) {
									continue;
								}
								if ( ! empty( $args['label'] ) ) {
									$strings[] = '<strong class="wc-item-meta-label">' . wp_kses_post( $key ) . ':</strong> ' . wp_kses_post( $value );
								} else {
									$strings[] = wp_kses_post( $value );
								}
							}
							if ( $strings !== [] ) {
								$item_meta = ( $args['before'] ?? '' ) . implode( $args['separator'] ?? ', ', $strings ) . ( $args['after'] ?? '' );
								if ( ! empty( $args['autop'] ) ) {
									$item_meta = wpautop( $item_meta );
								}
							}
						}
					}
				}
			}

			return $item_meta;
		}

		return false;
	}

	/**
	 * Returns appointment addons from cart.
	 *
	 * Retrieves and formats product addons from the cart item associated with this appointment.
	 * Only works if appointment is in 'in-cart' or 'was-in-cart' status and exists in cart.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $args Optional. Formatting arguments (same as get_addons()). Default empty array.
	 *
	 * @return string|false HTML formatted addon fields, or false if no addons or not in cart.
	 */
	public function get_addons_from_cart( array $args = [] ) {
		if ( null === WC()->cart ) {
			WC()->frontend_includes();
			WC()->session = new WC_Session_Handler();
			WC()->session->init();
			WC()->customer = new WC_Customer( get_current_user_id(), true );
			WC()->cart     = new WC_Cart();
		}

		$strings = [];
		foreach ( WC()->cart->get_cart() as $cart_item ) {
		    if ( isset( $cart_item['appointment'] ) ) {
		        $appointment_id = $cart_item['appointment']['_appointment_id'];
		        $appointment    = get_wc_appointment( $appointment_id );
		        $product        = $cart_item['data'];

		        if ( $product
		            && is_wc_appointment_product( $product )
		            && $this->get_product_id() === $product->get_id()
		            && $this->get_id() === $appointment_id
		            && isset( $cart_item['addons'] )
		        ) {
		            foreach ( $cart_item['addons'] as $addon ) {
		                $name = $addon['name'];

		                if ( $addon['price'] > 0 && apply_filters( 'woocommerce_addons_add_price_to_name', true, $addon ) ) {
		                    $name .= ' (' . wp_strip_all_tags( wc_price( WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon['price'] ) ) ) . ')';
		                }

		                $value = $args['autop'] ? wp_kses_post( $addon['value'] ) : wp_kses_post( make_clickable( trim( wp_strip_all_tags( $addon['value'] ) ) ) );

		                if ( $args['label'] ) {
		                    $strings[] = '<strong class="wc-item-meta-label">' . wp_kses_post( $name ) . ':</strong> ' . $value;
		                } else {
		                    $strings[] = wp_kses_post( $name ) . ': ' . $value;
		                }
		            }
		        }
		    }
		}

		if ( $strings !== [] ) {
			return $args['before'] . implode( $args['separator'], $strings ) . $args['after'];
		}

		return false;
	}

	/**
	 * Returns the product object corresponding to this appointment.
	 *
	 * Gets the WC_Product_Appointment object for the appointment's product.
	 * Uses cached product object if available, otherwise loads from product ID.
	 *
	 * @since 1.0.0
	 *
	 * @return WC_Product_Appointment|false Product object, or false if product cannot be loaded.
	 */
	public function get_product() { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
		try {
			if ( $this->get_product_id() !== 0 ) {
				// Preset order object.
				if ( $this->get_prop( 'product' ) ) {
					return $this->get_prop( 'product' );
				}

				return get_wc_product_appointment( $this->get_product_id() );
			}
		} catch ( Exception $e ) {
			return false;
		}
		return false;
	}

	/**
	 * Set product object.
	 *
	 * Used mainly for dummmy content.
	 *
	 * @since 1.0.0
	 *
	 * @param WC_Product_Appointment|mixed $value Product object to set.
	 *
	 * @return void
	 */
	public function set_product( $value ): void {
		if ( $value && is_a( $value, 'WC_Product_Appointment' ) ) {
			$this->set_prop( 'product', $value );
			if ( $value->get_id() ) {
				$this->set_product_id( $value->get_id() );
			}
		}
	}

	/**
	 * Returns the product name for this appointment.
	 *
	 * Gets the title/name of the product associated with this appointment.
	 *
	 * @since 1.0.0
	 *
	 * @return string|false Product name/title, or false if product cannot be loaded.
	 */
	public function get_product_name() { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
		try {
			if ( $this->get_product() ) {
				return $this->get_product()->get_title();
			}
		} catch ( Exception $e ) {
			return false;
		}
		return false;
	}

	/**
	 * Returns the order object corresponding to this appointment.
	 *
	 * Gets the WC_Order object for the appointment's order.
	 * Uses cached order object if available, otherwise loads from order ID with caching.
	 *
	 * @since 1.0.0
	 *
	 * @return WC_Order|false Order object, or false if order ID is 0 or order cannot be loaded.
	 */
	public function get_order() { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
		if ( $this->get_order_id() !== 0 ) {

			// Preset order object.
			if ( $this->get_prop( 'order' ) ) {
				return $this->get_prop( 'order' );
			}

			// Cache query.
			$cache_group = 'wc-appointment-order';
			$cache_key   = WC_Cache_Helper::get_cache_prefix( $cache_group ) . 'get_order' . $this->get_order_id();

			$data = wp_cache_get( $cache_key, $cache_group );

			if ( false === $data ) {
				$data = wc_get_order( $this->get_order_id() );

				wp_cache_set( $cache_key, $data, $cache_group );
			}

			return $data;
		}

		return false;
	}

	/**
	 * Set product order.
	 *
	 * Used mainly for dummmy content.
	 *
	 * @since 1.0.0
	 *
	 * @param WC_Order|mixed $value Order object to set.
	 *
	 * @return void
	 */
	public function set_order( $value ): void {
		if ( $value && is_a( $value, 'WC_Order' ) ) {
			$this->set_prop( 'order', $value );
			if ( $value->get_id() ) {
				$this->set_order_id( $value->get_id() );
			}
		}
	}

	/**
	 * Returns information about the customer of this appointment.
	 *
	 * Retrieves customer information from user account (if customer_id is set) or from order
	 * (if order_id is set). Falls back to "Guest" if no customer information is available.
	 *
	 * @since 1.0.0
	 *
	 * @param WC_Order|false $order Optional. Order object to use instead of loading from order_id. Default false.
	 *
	 * @return array<string, mixed> {
	 *     Customer information array.
	 *
	 *     @type string $name             Display name or "Guest".
	 *     @type string $full_name        Full name or "Guest".
	 *     @type string $first_name       First name.
	 *     @type string $last_name        Last name.
	 *     @type string $phone            Phone number.
	 *     @type string $email            Email address.
	 *     @type string $billing_address  Billing address.
	 *     @type string $shipping_address Shipping address.
	 *     @type int    $user_id          User ID (0 if guest).
	 * }
	 */
	public function get_customer( $order = false ) {
		// Defaults.
		$return = [
			'name'             => __( 'Guest', 'woocommerce-appointments' ),
			'full_name'        => __( 'Guest', 'woocommerce-appointments' ),
			'first_name'       => '',
			'last_name'        => '',
			'phone'            => '',
			'email'            => '',
			'billing_address'  => '',
			'shipping_address' => '',
			'user_id'          => 0,
		];

		// Appointment has customer ID.
		$user = $this->get_customer_id() !== 0 ? get_user_by( 'id', $this->get_customer_id() ) : 0;
		if ( $user ) {
			$return['name']       = $user->display_name;
			$return['first_name'] = $user->user_firstname;
			$return['last_name']  = $user->user_lastname;
			$return['full_name']  = trim( $user->user_firstname . ' ' . $user->user_lastname );
			$return['phone']      = $user->user_phone;
			$return['email']      = $user->user_email;
			$return['user_id']    = $this->get_customer_id();
		}

		// Appointment has order ID.
		if ( $this->get_order_id() !== 0 ) {

			$order = $order ?: $this->get_order();

			if ( $order ) {
				// First name.
				if ( $order->get_billing_first_name() && ! $return['first_name'] ) {
					$return['first_name'] = $order->get_billing_first_name();
				} elseif ( $order->get_shipping_first_name() && ! $return['first_name'] ) {
					$return['first_name'] = $order->get_shipping_first_name();
				}
				// Last name.
				if ( $order->get_billing_last_name() && ! $return['last_name'] ) {
					$return['last_name'] = $order->get_billing_last_name();
				} elseif ( $order->get_shipping_last_name() && ! $return['last_name'] ) {
					$return['last_name'] = $order->get_shipping_last_name();
				}
				// Full name.
				if ( $return['first_name'] || $return['last_name'] ) {
					$return['full_name'] = trim( $return['first_name'] . ' ' . $return['last_name'] );
					$return['name']      = trim( $return['first_name'] . ' ' . $return['last_name'] );
				}
				$customer_id = 0 !== absint( $order->get_customer_id() );
				if ( ! $customer_id && ( $return['first_name'] || $return['last_name'] ) ) {
					/* translators: %s: Guest name */
					$return['full_name'] = sprintf( _x( '%s (Guest)', 'Guest string with name from appointment order in brackets', 'woocommerce-appointments' ), $return['full_name'] );
					/* translators: %s: Guest name */
					$return['name'] = sprintf( _x( '%s (Guest)', 'Guest string with name from appointment order in brackets', 'woocommerce-appointments' ), $return['full_name'] );
				}
				// Address.
				if ( $order->get_formatted_billing_address() ) {
					$return['billing_address'] = $order->get_formatted_billing_address();
				} elseif ( $order->get_formatted_shipping_address() ) {
					$return['shipping_address'] = $order->get_formatted_shipping_address();
				}

				// Phone.
				if ( $order->get_billing_phone() && ! $return['phone'] ) {
					$return['phone'] = $order->get_billing_phone();
				} elseif ( $order->get_shipping_phone() && ! $return['phone'] ) {
					$return['phone'] = $order->get_shipping_phone();
				}

				// Email.
				if ( ! $return['email'] ) {
					$return['email'] = $order->get_billing_email();
				}

				// Display name.
				$return['name'] = $return['full_name'];

				// User ID.
				if ( $return['user_id'] === 0 ) {
					$return['user_id'] = $order->get_customer_id();
				}

				return (object) $return;
			}
		}

		// Guest and no order.
		return (object) $return;
	}

	/**
	 * Returns true if product has timezones, otherwise false.
	 *
	 * @since 1.0.0
	 *
	 * @return bool True if product has timezones enabled, false otherwise.
	 */
	public function get_product_has_timezones(): bool {
		try {
			if ( $this->get_product() ) {
				return $this->get_product()->has_timezones();
			}
		} catch ( Exception $e ) {
			return false;
		}
	}

	/**
     * Is edited from post.php's meta box.
     */
    public function is_edited_from_meta_box(): bool {
		return (
			! empty( $_POST['wc_appointments_details_meta_box_nonce'] )
			&&
			wp_verify_nonce( $_POST['wc_appointments_details_meta_box_nonce'], 'wc_appointments_details_meta_box' )
		);
	}

	/**
	 * Schedule events for this appointment.
	 *
	 * Schedules reminder and completion events for the appointment based on status changes.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $get_changes Optional. Array of changed properties. Default empty array.
	 *
	 * @return void
	 */
	public function schedule_events( array $get_changes = [] ): void {
		$order          = $this->get_order();
		if ($order) {
            $order->get_status();
        }
		if ($order) {
            $order->get_payment_method();
        }
		if ($order) {
            $order->get_created_via();
        }

		// Check if emails are set.
		$is_reminder_set = as_next_scheduled_action( 'wc-appointment-reminder', [ $this->get_id() ], 'wca' );
		$is_complete_set = as_next_scheduled_action( 'wc-appointment-complete', [ $this->get_id() ], 'wca' );

		// Appointment is edited in admin.
		if ( $this->is_edited_from_meta_box() ) {
			if ( isset( $get_changes['status'] ) ) {
				// Status is OK.
				if ( $this->has_status( get_wc_appointment_statuses( 'scheduled' ) ) ) {
					$this->maybe_schedule_event( 'reminder' );
					$this->maybe_schedule_event( 'complete' );
				} else {
					// Use all-actions unschedule to clear any duplicates.
					as_unschedule_all_actions( 'wc-appointment-reminder', [ $this->get_id() ], 'wca' );
					as_unschedule_all_actions( 'wc-appointment-complete', [ $this->get_id() ], 'wca' );
					if ( ! $this->has_status( WC_Appointments_Constants::STATUS_COMPLETE ) ) {
						as_unschedule_all_actions( 'wc-appointment-follow-up', [ $this->get_id() ], 'wca' );
					}
				}
			} elseif ( isset( $get_changes['start'] ) ) {
				// Reset duplicate-send guard when start changes.
				delete_post_meta( $this->get_id(), '_wca_reminder_sent_for_start_ts' );
				delete_post_meta( $this->get_id(), '_wca_reminder_sent_at' );
				// Status is OK.
				if ( $this->has_status( get_wc_appointment_statuses( 'scheduled' ) ) ) {
					$this->maybe_schedule_event( 'reminder' );
					$this->maybe_schedule_event( 'complete' );
				} else {
					if ( false !== $is_reminder_set ) {
						$this->maybe_schedule_event( 'reminder' );
					}
					if ( false !== $is_complete_set ) {
						$this->maybe_schedule_event( 'complete' );
					}
				}
			}
		// When status is OK, always schedule.
		} elseif ( $this->has_status( get_wc_appointment_statuses( 'scheduled' ) ) ) {
			$this->maybe_schedule_event( 'reminder' );
			$this->maybe_schedule_event( 'complete' );
		// Unschedule emails in all other cases.
		} else {
			// Use all-actions unschedule to clear any duplicates.
			as_unschedule_all_actions( 'wc-appointment-reminder', [ $this->get_id() ], 'wca' );
			as_unschedule_all_actions( 'wc-appointment-complete', [ $this->get_id() ], 'wca' );
			if ( ! $this->has_status( 'complete' ) ) {
				as_unschedule_all_actions( 'wc-appointment-follow-up', [ $this->get_id() ], 'wca' );
			}
		}
	}

	/**
	 * Checks if appointment end date has already passed.
	 *
	 * @since 4.9.4
	 * @return bool True if current time is bigger than appointment end date.
	 */
	public function passed_end_date(): bool
    {
        return $this->get_end() && ( $this->get_end() < current_time( 'timestamp' ) );
    }

	/**
     * Schedule event for this appointment.
     *
     * Schedules Action Scheduler events (reminder, complete, follow-up emails) for this appointment.
     * Uses unique scheduling when available to prevent duplicate events.
     *
     * @since 1.0.0
     *
     * @param string $type Event type to schedule ('reminder', 'complete', or 'follow-up').
     *
     * @return bool True if event was scheduled, false otherwise.
     */
    public function maybe_schedule_event( string $type ): bool {
		$reminder_mailer   = WC()->mailer()->emails['WC_Email_Appointment_Reminder'];
		$reminder_time     = $reminder_mailer->get_option( 'reminder_time', '1 day' );
		$follow_up_mailer  = WC()->mailer()->emails['WC_Email_Appointment_Follow_Up'];
		$follow_up_time    = $follow_up_mailer->get_option( 'follow_up_time', '1 day' );
		$timezone_addition = - wc_appointment_timezone_offset();

		// Prefer unique scheduling when available to prevent duplicates at insertion time.
		$__schedule_fn = function_exists( 'as_schedule_unique_action' ) ? 'as_schedule_unique_action' : 'as_schedule_single_action';

		// Trigger action hook when the event is scheduled.
		do_action( 'woocommerce_appointment_maybe_schedule_event', $type, $this );

		// Timestamps.
		$reminder_timestamp  = $timezone_addition + strtotime( '-' . apply_filters( 'woocommerce_appointments_remind_before_time', $reminder_time, $this ), $this->get_start() );
		$complete_timestamp  = $timezone_addition + apply_filters( 'woocommerce_appointments_complete_time', $this->get_end(), $this );
		$follow_up_timestamp = $timezone_addition + strtotime( '+' . apply_filters( 'woocommerce_appointments_follow_up_time', $follow_up_time, $this ), $this->get_end() );

		switch ( $type ) {
			case 'reminder':
				if ( $this->get_start() && ! $this->passed_end_date() ) {
					// Clear any and all duplicate scheduled reminders for this appointment.
					as_unschedule_all_actions( 'wc-appointment-reminder', [ $this->get_id() ], 'wca' );
					// Use unique scheduling when possible.
					return is_null( $__schedule_fn( $reminder_timestamp, 'wc-appointment-reminder', [ $this->get_id() ], 'wca' ) );

				}
				break;
			case 'complete':
				if ( $this->get_end() !== 0 ) {
					// Clear any duplicates before rescheduling.
					as_unschedule_all_actions( 'wc-appointment-complete', [ $this->get_id() ], 'wca' );
					$return_complete = is_null( $__schedule_fn( $complete_timestamp, 'wc-appointment-complete', [ $this->get_id() ], 'wca' ) );
					as_unschedule_all_actions( 'wc-appointment-follow-up', [ $this->get_id() ], 'wca' );
					$return_follow_up = is_null( $__schedule_fn( $follow_up_timestamp, 'wc-appointment-follow-up', [ $this->get_id() ], 'wca' ) );
					return $return_complete || $return_follow_up;
				}
				break;
		}

		return false;
	}

	/**
     * Returns the cancel URL for an appointment.
     *
     * Generates a nonced URL that customers can use to cancel their appointment.
     * URL points to the My Account appointments page with cancel parameters.
     *
     * @since 1.0.0
     *
     * @param string $redirect Optional. Redirect URL after cancellation. Default empty.
     *
     * @return string Cancel URL with nonce and query parameters.
     */
    public function get_cancel_url( string $redirect = '' ): string {
		return apply_filters(
			'appointments_cancel_appointment_url',
			wp_nonce_url(
				add_query_arg(
					[
						'cancel_appointment' => 'true',
						'appointment_id'     => $this->get_id(),
						'redirect'           => $redirect,
					],
					wc_get_page_permalink( 'myaccount' )
				),
				'woocommerce-appointments-cancel_appointment'
			),
			$this
		);
	}

	/**
     * Returns the reschedule URL for an appointment.
     *
     * Generates a URL that customers can use to reschedule their appointment.
     * URL points to the My Account reschedule endpoint for this appointment.
     *
     * @since 1.0.0
     *
     * @param string $redirect Optional. Unused parameter (kept for backward compatibility). Default empty.
     *
     * @return string Reschedule URL for the appointment.
     */
    public function get_reschedule_url( string $redirect = '' ): string {
		return apply_filters(
			'appointments_reschedule_appointment_url',
			esc_url(
				wc_get_endpoint_url(
					'reschedule',
					$this->get_id(),
					wc_get_page_permalink( 'myaccount' )
				)
			),
			$this
		);
	}

	/*
	|--------------------------------------------------------------------------
	| Legacy.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Actually create the appointment in the database.
	 *
	 * Sets the appointment status and saves it to the database.
	 * This is the method that persists a new appointment object.
	 *
	 * @since 1.0.0
	 *
	 * @param string $status Optional. Initial status for the appointment. Default 'unpaid'.
	 *
	 * @return void
	 */
	public function create( string $status = 'unpaid' ): void {
		$this->set_status( $status );
		$this->save();
	}

	/**
     * Mark appointment as paid when the associated order is paid.
     *
     * Checks if the appointment is eligible to be marked as paid (unpaid, confirmed, or in-cart status)
     * and if the associated order has been paid (processing or completed status).
     * For partial-payment appointments, checks the specific order item's deposit status.
     * Updates appointment status to 'paid' and saves if conditions are met.
     *
     * @since 1.0.0
     *
     * @return bool True if appointment was marked as paid, false otherwise.
     */
    public function paid(): bool {
		$order         = $this->get_order();
		$order_is_paid = $order && $order->has_status( [ 'processing', 'completed' ] );

		// Statuses that can be marked as paid without checking order status.
		$eligible_statuses = [ WC_Appointments_Constants::STATUS_UNPAID, WC_Appointments_Constants::STATUS_CONFIRMED ];

		// For partial-payment appointments, only mark as paid if THIS appointment's deposit is fully settled.
		// This prevents overriding the partial-payment status when only a deposit is paid.
		$is_partial_payment_complete = false;
		if ( $this->has_status( 'wc-partial-payment' ) ) {
			$is_partial_payment_complete = $order_is_paid;
			if ( $order ) {
				$order_item_id = $this->get_order_item_id();
				$order_items   = $order->get_items();
				$item          = isset( $order_items[ $order_item_id ] ) ? $order_items[ $order_item_id ] : null;

				if ( $item ) {
					// Check official WC Deposits first.
					if ( class_exists( 'WC_Deposits_Order_Item_Manager' ) ) {
						if ( WC_Deposits_Order_Item_Manager::is_deposit( $item ) ) {
							// This specific item is a deposit - check if it's fully paid.
							$is_partial_payment_complete = WC_Deposits_Order_Item_Manager::is_fully_paid( $item, $order );
						} else {
							// This item is NOT a deposit - it's fully paid by default.
							$is_partial_payment_complete = $order_is_paid;
						}
					} elseif ( class_exists( 'Webtomizer\WCDP\WC_Deposits' ) ) {
						// Webtomizer Deposits - check item meta.
						$deposit_full_amount = $item->get_meta( '_deposit_full_amount' );
						if ( ! empty( $deposit_full_amount ) ) {
							// This item is a deposit - check if fully paid via order status.
							// Webtomizer marks order as 'completed' when fully paid.
							$is_partial_payment_complete = $order->has_status( 'completed' );
						} else {
							// This item is NOT a deposit - it's fully paid by default.
							$is_partial_payment_complete = $order_is_paid;
						}
					}
				}
			}
		}

		// For in-cart appointments, require the order to be paid.
		$is_in_cart_paid = $order_is_paid && $this->has_status( WC_Appointments_Constants::STATUS_IN_CART );

		if ( $this->has_status( $eligible_statuses ) || $is_partial_payment_complete || $is_in_cart_paid ) {
			$this->set_status( WC_Appointments_Constants::STATUS_PAID );
			$this->save();

			return true;
		}

		return false;
	}

	/**
     * Populate the data with the id of the appointment provided.
     *
     * Loads appointment data from the database using the provided appointment ID.
     * Queries for the post belonging to this appointment and stores the data.
     *
     * @since 1.0.0
     *
     * @param int $appointment_id Appointment ID to load data for.
     *
     * @return bool True if data was loaded successfully, false otherwise.
     */
    public function populate_data( int $appointment_id ): bool {
		$this->set_defaults();
		$this->set_id( $appointment_id );
		$this->data_store->read( $this );

		return 0 < $this->get_id();
	}

	/**
     * Set the new status for this appointment.
     *
     * Updates the appointment status if the new status is allowed.
     * Validates against allowed statuses (user, cancel, and all statuses) before updating.
     * Automatically saves the appointment after status change.
     *
     * @since 1.0.0
     *
     * @param string $status New status to set (must be one of the allowed statuses).
     *
     * @return bool True if status was updated successfully, false if status is not allowed.
     */
    public function update_status( string $status ): bool {
		$this->get_status( 'edit' );

		$allowed_statuses = [
			'was-in-cart' => __( 'Was In Cart', 'woocommerce-appointments' ),
		];

		$allowed_statuses = array_unique(
			array_merge(
				$allowed_statuses,
				get_wc_appointment_statuses( null, true ),
				get_wc_appointment_statuses( 'user', true ),
				get_wc_appointment_statuses( 'cancel', true )
			)
		);

		$allowed_status_keys = array_keys( $allowed_statuses );

		if ( in_array( $status, $allowed_status_keys ) ) {
			$this->set_status( $status );
			$this->save();

			return true;
		}

		return false;
	}

	/**
     * Set the new customer status for this appointment.
     *
     * Updates the customer status (expected, arrived, no-show) if the new status is allowed.
     * Validates against allowed customer statuses before updating.
     * Automatically saves the appointment after status change.
     *
     * @since 1.0.0
     *
     * @param string $status New customer status to set (must be: 'expected', 'arrived', or 'no-show').
     *
     * @return bool True if customer status was updated successfully, false if status is not allowed.
     */
    public function update_customer_status( string $status ): bool {
		$this->get_customer_status( 'edit' );
		$allowed_statuses = get_wc_appointment_statuses( 'customer' );
		$allowed_statuses = array_keys( $allowed_statuses );

		if ( in_array( $status, $allowed_statuses ) ) {
			$this->set_customer_status( $status );
			$this->save();

			return true;
		}

		return false;
	}

	/**
     * Magic __isset method for backwards compatibility.
     *
     *
     */
    public function __isset( string $key ): bool {
		$legacy_props = [ 'appointment_date', 'modified_date', 'populated', 'post', 'custom_fields' ];
		return $this->get_id() && (in_array( $key, $legacy_props ) || is_callable( [ $this, "get_{$key}" ] ));
	}

	/**
	 * Magic __get method for backwards compatibility.
	 *
	 * @param string $key
	 *
	 * @return mixed
	 */
	public function __get( $key ) {
		// wc_doing_it_wrong( $key, 'Appointment properties should not be accessed directly.', '3.0.0' ); @todo deprecated when 2.6.x dropped
        if ('appointment_date' === $key) {
            return $this->get_date_created();
        }
        if ('modified_date' === $key) {
            return $this->get_date_modified();
        }
        if ('populated' === $key) {
            return $this->get_object_read();
        }
        if ('post' === $key) {
            return get_post( $this->get_id() );
        }
        if ('custom_fields' === $key) {
            return get_post_meta( $this->get_id() );
        }
        // wc_doing_it_wrong( $key, 'Appointment properties should not be accessed directly.', '3.0.0' ); @todo deprecated when 2.6.x dropped
		if (is_callable( [ $this, "get_{$key}" ] )) {
            return $this->{"get_{$key}"}();
        }
        return get_post_meta( $this->get_id(), '_' . $key, true );
	}

	/**
	 * Get date localized to client timezone.
	 *
	 * Converts a timestamp from server timezone to the appointment's local timezone.
	 * Returns null if timezone conversion fails (invalid timezone).
	 *
	 * @since 1.0.0
	 *
	 * @param int $date Timestamp in server timezone to convert.
	 *
	 * @return int|null Timestamp in client's timezone, or null if conversion fails.
	 */
	public function get_localized_date( int $date ): ?int {
		$localized_date = null;

		// Timezone may not exist so wrap it in a try/catch block
		try {
			$local_timezone  = new DateTimeZone( $this->get_local_timezone() );
			$server_timezone = wc_timezone_string();

			// Create DateTime in server's timezone (otherwise UTC is assumed).
			$dt = new DateTime( date( 'Y-m-d\TH:i:s', $date ), new DateTimeZone( $server_timezone ) );
			$dt->setTimezone( $local_timezone );

			// Calling simply `getTimestamp` will not calculate the timezone.
			$localized_date = strtotime( $dt->format( 'Y-m-d H:i:s' ) );
		} catch ( Exception $e ) {
			return null;
		}

		return $localized_date;
	}

	/**
	 * Get the appointment timezone, falling back to server timezone if not set.
	 *
	 * Returns the local timezone for this appointment, or the server timezone
	 * if no local timezone is configured.
	 *
	 * @since 4.19.0
	 *
	 * @return string Timezone string (e.g., 'America/New_York' or 'UTC+5').
	 */
	public function get_appointment_timezone(): string {
		$timezone = $this->get_local_timezone();
		return $timezone ?: wc_timezone_string();
	}

	/**
     * Indicate whether the appointment is active, i.e. not cancelled or refunded.
     *
     * Checks if the appointment is active by verifying:
     * - Appointment status is not 'cancelled'
     * - If order exists, order status is not 'cancelled' or 'refunded'
     * - Appointment has a valid ID
     *
     * @since 4.10.8
     *
     * @return bool True if appointment is active, false otherwise.
     */
    public function is_active(): bool {
		$appointment_status = $this->get_status();
		$order_id           = WC_Appointment_Data_Store::get_appointment_order_id( $this->get_id() );

		// Check only appointment status if no order associated with appointment (eg: manually created appointment without order).
		if ( 0 === $order_id && 'cancelled' !== $appointment_status ) {
			return true;
		}

		$order = wc_get_order( $order_id );

		// Dangling appointment, probably not a valid one.
		if ( ! is_a( $order, 'WC_Order' ) ) {
			return false;
		}

		$order_status = $order->get_status();
        // Don't consider the appointment active for cancelled appointment, or if the order is cancelled or refunded.
        return !('cancelled' === $appointment_status || 'refunded' === $order_status || 'cancelled' === $order_status);
	}

	/**
     * See if this appointment is scheduled on a date.
     *
     * Checks if the appointment overlaps with the given time slot (day).
     * Handles both single-day and multi-day appointments.
     * This method is deprecated - use is_within_slot() or is_intersecting_slot() instead.
     *
     * @since 1.0.0
     * @deprecated 4.2.0 Use is_within_slot() or is_intersecting_slot() instead.
     *
     * @param int $slot_start Slot start timestamp.
     * @param int $slot_end   Slot end timestamp.
     *
     * @return bool True if appointment is scheduled on the given day/slot, false otherwise.
     */
    public function is_scheduled_on_day( int $slot_start, int $slot_end ): bool {
		_deprecated_function( __METHOD__, '4.2.0' );

		$is_scheduled         = false;
		$loop_date            = $this->get_start();
		$multiday_appointment = date( 'Y-m-d', $this->get_start() ) < date( 'Y-m-d', $this->get_end() );

		if ( $multiday_appointment ) {
			if ( date( 'YmdHi', $slot_end ) > date( 'YmdHi', $this->get_start() ) && date( 'YmdHi', $slot_start ) < date( 'YmdHi', $this->get_end() ) ) {
				$is_scheduled = true;
			} else {
				$is_scheduled = false;
			}
		} else {
			while ( $loop_date <= $this->get_end() ) {
				if ( date( 'Y-m-d', $loop_date ) === date( 'Y-m-d', $slot_start ) ) {
					$is_scheduled = true;
				}
				$loop_date = strtotime( '+1 day', $loop_date );
			}
		}

		/**
		 * Filter the appointment objects is_scheduled_on_day method return result.
		 *
		 * @since 1.9.13
		 *
		 * @param bool $is_scheduled
		 * @param WC_Appointment $appointment
		 * @param int $slot_start
		 * @param int $slot_end
		 */
		return apply_filters( 'woocommerce_appointment_is_scheduled_on_day', $is_scheduled, $this, $slot_start, $slot_end );
	}
}
