<?php
/**
 * Booking Handler
 *
 * Handles appointment booking operations including validation, availability checks,
 * and appointment creation. Follows single responsibility principle by separating
 * booking logic from other concerns.
 *
 * @package WooCommerce Appointments
 * @since 5.1.0
 */

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

/**
 * WC_Appointment_Booking_Handler class.
 *
 * Centralizes appointment booking operations including validation, availability
 * checks, and appointment creation with proper error handling.
 *
 * @since 5.1.0
 */
class WC_Appointment_Booking_Handler {

	/**
	 * Booking lock instance.
	 *
	 * @var WC_Appointment_Booking_Lock
	 */
	private $lock;

	/**
	 * Constructor.
	 *
	 * @since 4.0.0
	 */
	public function __construct() {
		$this->lock = new WC_Appointment_Booking_Lock();
	}

	/**
	 * Create an appointment with validation and availability checks.
	 *
	 * Validates appointment data, checks availability, acquires lock, creates
	 * appointment, and releases lock. Handles errors gracefully.
	 *
	 * TIMEZONE HANDLING:
	 * =================
	 * This function receives appointment data with timestamps that are UTC but represent
	 * times in the site's configured timezone. The timestamps should already be converted
	 * from customer timezone to site timezone (if applicable) by wc_appointments_get_posted_data().
	 *
	 * Timestamp Format:
	 * - start_date, end_date: UTC Unix timestamps (seconds since epoch)
	 * - These timestamps represent times in site 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)
	 *
	 * See TIMEZONE_ARCHITECTURE.md for detailed explanation.
	 *
	 * @since 4.0.0
	 *
	 * @param int                  $product_id            Product ID for the appointment.
	 * @param array<string, mixed> $appointment_data      Appointment data. {
	 *     @type string $start_date Start date/time (timestamp or date string).
	 *     @type string $end_date   End date/time (timestamp or date string).
	 *     @type int    $staff_id   Staff ID (single staff).
	 *     @type array  $staff_ids  Staff IDs (multiple staff).
	 *     @type string $timezone   Timezone string (site timezone).
	 *     @type string $local_timezone Customer timezone (if different from site).
	 *     @type bool   $all_day    Whether appointment is all day.
	 *     @type int    $qty        Quantity.
	 *     @type float  $cost       Appointment cost.
	 * }
	 * @param string               $status                Optional. Initial appointment status. Default 'confirmed'.
	 * @param bool                 $exact                 Optional. If false, find next available slot if requested time unavailable. Default false.
	 *
	 * @return WC_Appointment|false|WP_Error Appointment object on success, false on failure, WP_Error on validation/availability error.
	 */
	public function create_appointment( int $product_id, array $appointment_data = [], string $status = WC_Appointments_Constants::STATUS_CONFIRMED, bool $exact = false ) {
		$product = wc_get_product( $product_id );

		if ( ! is_wc_appointment_product( $product ) ) {
			return new WP_Error( 'invalid_product', __( 'Invalid appointment product.', 'woocommerce-appointments' ) );
		}

		// Prepare and validate appointment data.
		$prepared_data = $this->prepare_appointment_data( $product, $appointment_data, $exact );
		if ( is_wp_error( $prepared_data ) ) {
			return $prepared_data;
		}

		// Check availability.
		$availability_result = $this->check_availability( $product, $prepared_data, $exact );
		if ( is_wp_error( $availability_result ) ) {
			return $availability_result;
		}

		// Update prepared data with availability result (e.g., selected staff).
		$prepared_data = array_merge( $prepared_data, $availability_result );

		// Normalize staff data after merging - ensure staff_id is not an array.
		if ( isset( $prepared_data['staff_ids'] ) && is_array( $prepared_data['staff_ids'] ) ) {
			// If staff_ids is an array, keep it as is, but don't set staff_id
			if ( ! empty( $prepared_data['staff_id'] ) ) {
				unset( $prepared_data['staff_id'] );
			}
		} elseif ( isset( $prepared_data['staff_id'] ) && is_array( $prepared_data['staff_id'] ) ) {
			// If staff_id is an array (shouldn't happen, but handle it), convert to staff_ids
			$prepared_data['staff_ids'] = $prepared_data['staff_id'];
			unset( $prepared_data['staff_id'] );
		} elseif ( isset( $prepared_data['staff_id'] ) && ! empty( $prepared_data['staff_id'] ) ) {
			// Ensure staff_id is a scalar value (int or string)
			$prepared_data['staff_id'] = is_scalar( $prepared_data['staff_id'] ) ? $prepared_data['staff_id'] : (string) $prepared_data['staff_id'];
		}

		// Get staff value for lock (lock class handles arrays, but we normalize here for consistency).
		$lock_staff_id = 0;
		if ( ! empty( $prepared_data['staff_id'] ) && is_scalar( $prepared_data['staff_id'] ) ) {
			$lock_staff_id = $prepared_data['staff_id'];
		} elseif ( ! empty( $prepared_data['staff_ids'] ) ) {
			// Lock class can handle arrays, so pass as-is
			$lock_staff_id = $prepared_data['staff_ids'];
		}

		// Acquire booking lock.
		$lock_acquired = $this->lock->acquire_with_retry(
			$product_id,
			$prepared_data['start_date'],
			$prepared_data['end_date'],
			$lock_staff_id
		);

		if ( ! $lock_acquired ) {
			return new WP_Error(
				'booking_locked',
				__( 'This time slot is currently being processed. Please try again in a moment.', 'woocommerce-appointments' )
			);
		}

		try {
			// Final cleanup: normalize staff data before passing to get_wc_appointment().
			// The appointment object uses 'staff_ids' (not 'staff_id') in its data structure.
			// We need to ensure staff_ids is set correctly to avoid triggering staff transitions.
			if ( isset( $prepared_data['staff_id'] ) && ! empty( $prepared_data['staff_id'] ) ) {
				// If staff_id is set and is scalar, convert to staff_ids format
				if ( is_scalar( $prepared_data['staff_id'] ) ) {
					$prepared_data['staff_ids'] = $prepared_data['staff_id'];
				} elseif ( is_array( $prepared_data['staff_id'] ) ) {
					// If staff_id is an array, move it to staff_ids
					$prepared_data['staff_ids'] = $prepared_data['staff_id'];
				}
				// Remove staff_id as appointment object uses staff_ids
				unset( $prepared_data['staff_id'] );
			} elseif ( isset( $prepared_data['staff_ids'] ) && is_array( $prepared_data['staff_ids'] ) ) {
				// staff_ids is already an array, keep it
				// Remove staff_id if it exists to avoid confusion
				unset( $prepared_data['staff_id'] );
			} elseif ( isset( $prepared_data['staff_ids'] ) && ! is_array( $prepared_data['staff_ids'] ) && ! empty( $prepared_data['staff_ids'] ) ) {
				// staff_ids is a scalar value, keep it as is
				// Remove staff_id if it exists
				unset( $prepared_data['staff_id'] );
			}
			
			// Create appointment with normalized data.
			$appointment = get_wc_appointment( $prepared_data );
			$appointment->create( $status );

			// Release lock on success.
			$this->lock->release(
				$product_id,
				$prepared_data['start_date'],
				$prepared_data['end_date'],
				$lock_staff_id
			);

			return $appointment;
		} catch ( Exception $e ) {
			// Release lock on error.
			$this->lock->release(
				$product_id,
				$prepared_data['start_date'],
				$prepared_data['end_date'],
				$lock_staff_id
			);

			wc_get_logger()->error(
				sprintf( 'Failed to create appointment: %s', $e->getMessage() ),
				[ 'source' => 'wc-appointments' ]
			);

			return false;
		}
	}

	/**
	 * Prepare and validate appointment data.
	 *
	 * Merges provided data with defaults, validates required fields, and
	 * calculates missing dates if needed.
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment $product           Product object.
	 * @param array<string, mixed>   $appointment_data   Appointment data.
	 * @param bool                  $exact              Whether exact time is required.
	 *
	 * @return array<string, mixed>|WP_Error Prepared appointment data or error.
	 */
	private function prepare_appointment_data( WC_Product_Appointment $product, array $appointment_data, bool $exact ) {
		// Merge with defaults.
		$defaults = [
			'product_id' => $product->get_id(),
			'start_date' => '',
			'end_date'   => '',
			'staff_id'   => '',
			'staff_ids'  => '',
			'timezone'   => '',
			'all_day'    => false,
			'qty'        => 1,
		];

		$prepared = wp_parse_args( $appointment_data, $defaults );

		// Calculate start date if not provided.
		if ( empty( $prepared['start_date'] ) ) {
			$min_date   = $product->get_min_date_a();
			$prepared['start_date'] = strtotime( "+{$min_date['value']} {$min_date['unit']}", current_time( 'timestamp' ) );
		} else {
			$prepared['start_date'] = is_numeric( $prepared['start_date'] ) ? (int) $prepared['start_date'] : strtotime( $prepared['start_date'] );
		}

		// Calculate end date if not provided.
		if ( empty( $prepared['end_date'] ) ) {
			$prepared['end_date'] = strtotime( '+' . $product->get_duration() . ' ' . $product->get_duration_unit(), $prepared['start_date'] );
		} else {
			$prepared['end_date'] = is_numeric( $prepared['end_date'] ) ? (int) $prepared['end_date'] : strtotime( $prepared['end_date'] );
		}

		// Normalize staff_id - handle both single staff and multiple staff.
		// If staff_ids is an array, keep it as staff_ids, otherwise use staff_id.
		if ( ! empty( $prepared['staff_ids'] ) && is_array( $prepared['staff_ids'] ) ) {
			// Keep staff_ids as array, don't set staff_id
			// staff_id will be empty/0 which is fine
		} elseif ( ! empty( $prepared['staff_id'] ) ) {
			// staff_id is set, use it
		} elseif ( ! empty( $prepared['staff_ids'] ) ) {
			// staff_ids is a single value (not array), convert to staff_id
			$prepared['staff_id'] = $prepared['staff_ids'];
			unset( $prepared['staff_ids'] );
		}

		// Validate dates.
		if ( $prepared['start_date'] <= 0 || $prepared['end_date'] <= 0 ) {
			return new WP_Error( 'invalid_dates', __( 'Invalid appointment dates.', 'woocommerce-appointments' ) );
		}

		if ( $prepared['end_date'] <= $prepared['start_date'] ) {
			return new WP_Error( 'invalid_dates', __( 'End date must be after start date.', 'woocommerce-appointments' ) );
		}

		return $prepared;
	}

	/**
	 * Check availability for appointment slot.
	 *
	 * Checks if the requested time slot is available, optionally finding the
	 * next available slot if exact time is not required.
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment $product           Product object.
	 * @param array<string, mixed>    $appointment_data  Prepared appointment data.
	 * @param bool                   $exact              Whether exact time is required.
	 *
	 * @return array<string, mixed>|WP_Error Availability result with dates/staff or error.
	 */
	private function check_availability( WC_Product_Appointment $product, array $appointment_data, bool $exact ) {
		$start_date = $appointment_data['start_date'];
		$end_date   = $appointment_data['end_date'];
		$staff_id   = $appointment_data['staff_id'] ?? $appointment_data['staff_ids'] ?? null;
		$qty        = $appointment_data['qty'] ?? 1;
		$all_day    = $appointment_data['all_day'] ?? false;
		$max_date   = $product->get_max_date_a();
		$date_diff  = $all_day ? DAY_IN_SECONDS : $end_date - $start_date;

		$searching = true;
		$attempts  = 0;
		$max_attempts = 100; // Prevent infinite loops.

		while ( $searching && $attempts < $max_attempts ) {
			$attempts++;

			$available_appointments = wc_appointments_get_total_available_appointments_for_range(
				$product,
				$start_date,
				$end_date,
				$staff_id,
				$qty
			);

			if ( $available_appointments && ! is_wp_error( $available_appointments ) ) {
				// Availability found.
				$result = [
					'start_date' => $start_date,
					'end_date'   => $end_date,
				];

				// Handle staff selection if multiple staff available.
				if ( ! $staff_id && is_array( $available_appointments ) ) {
					// Get first available staff ID (single value, not array)
					$selected_staff_id = current( array_keys( $available_appointments ) );
					// Use staff_ids (appointment object uses staff_ids, not staff_id)
					$result['staff_ids'] = is_numeric( $selected_staff_id ) ? (int) $selected_staff_id : $selected_staff_id;
				} elseif ( $staff_id ) {
					// If staff_id was provided, convert to staff_ids format for appointment object
					$result['staff_ids'] = is_numeric( $staff_id ) ? (int) $staff_id : $staff_id;
				}

				return $result;
			}

			// If exact time required, return error.
			if ( $exact ) {
				return new WP_Error(
					'unavailable',
					__( 'The requested time slot is not available.', 'woocommerce-appointments' )
				);
			}

			// Try next slot.
			$start_date += $date_diff;
			$end_date   += $date_diff;

			// Check if we've exceeded max date.
			$max_timestamp = strtotime( "+{$max_date['value']} {$max_date['unit']}", current_time( 'timestamp' ) );
			if ( $end_date > $max_timestamp ) {
				return new WP_Error(
					'unavailable',
					__( 'No available time slots found within the booking window.', 'woocommerce-appointments' )
				);
			}
		}

		return new WP_Error(
			'unavailable',
			__( 'Unable to find available time slot.', 'woocommerce-appointments' )
		);
	}

	/**
	 * Create appointment from cart data.
	 *
	 * Creates an appointment from cart item metadata. Used when adding
	 * appointments to cart.
	 *
	 * @since 4.0.0
	 *
	 * @param array<string, mixed> $cart_item_meta Cart item meta containing appointment data.
	 * @param int                  $product_id     Product ID.
	 * @param string               $status         Optional. Initial appointment status. Default 'in-cart'.
	 *
	 * @return WC_Appointment|WP_Error Appointment object on success, WP_Error on failure.
	 */
	public function create_from_cart_data( array $cart_item_meta, int $product_id, string $status = WC_Appointments_Constants::STATUS_IN_CART ) {
		if ( empty( $cart_item_meta['appointment'] ) ) {
			return new WP_Error( 'missing_data', __( 'Missing appointment data in cart item.', 'woocommerce-appointments' ) );
		}

		$appointment_data = [
			'product_id'     => $product_id,
			'cost'           => $cart_item_meta['appointment']['_cost'] ?? 0,
			'start_date'     => $cart_item_meta['appointment']['_start_date'] ?? '',
			'end_date'       => $cart_item_meta['appointment']['_end_date'] ?? '',
			'all_day'        => $cart_item_meta['appointment']['_all_day'] ?? false,
			'qty'            => $cart_item_meta['appointment']['_qty'] ?? 1,
			'timezone'       => $cart_item_meta['appointment']['_timezone'] ?? '',
			'local_timezone' => $cart_item_meta['appointment']['_local_timezone'] ?? '',
		];

		// Handle staff.
		if ( isset( $cart_item_meta['appointment']['_staff_id'] ) ) {
			$appointment_data['staff_id'] = $cart_item_meta['appointment']['_staff_id'];
		}

		if ( isset( $cart_item_meta['appointment']['_staff_ids'] ) ) {
			$appointment_data['staff_ids'] = $cart_item_meta['appointment']['_staff_ids'];
		}

		// Use exact=true for cart data since dates are already selected.
		return $this->create_appointment( $product_id, $appointment_data, $status, true );
	}
}
