<?php
/**
 * Booking Lock Manager
 *
 * Handles booking locks to prevent race conditions during appointment creation.
 * Uses exponential backoff for retry attempts with performance optimizations.
 *
 * @package WooCommerce Appointments
 * @since 4.0.0
 */

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

/**
 * WC_Appointment_Booking_Lock class.
 *
 * Manages transient-based locks for appointment booking slots to prevent
 * double-booking in concurrent scenarios.
 *
 * @since 4.0.0
 */
class WC_Appointment_Booking_Lock {

	/**
	 * Default lock TTL in seconds.
	 *
	 * @var int
	 */
	const DEFAULT_LOCK_TTL = 5;

	/**
	 * Maximum number of retry attempts.
	 *
	 * @var int
	 */
	const MAX_RETRIES = 3;

	/**
	 * Base delay in microseconds for exponential backoff.
	 *
	 * @var int
	 */
	const BASE_DELAY_MICROSECONDS = 100000; // 100ms

	/**
	 * Acquire a booking lock for a specific time slot.
	 *
	 * Creates a transient-based lock to prevent concurrent booking attempts
	 * for the same appointment slot. Uses rounded timestamps to reduce lock
	 * granularity and improve performance.
	 *
	 * @since 4.0.0
	 *
	 * @param int       $product_id Product ID.
	 * @param int       $start_date Start timestamp.
	 * @param int       $end_date   End timestamp.
	 * @param int|int[] $staff_id   Optional. Staff ID(s). Default 0.
	 *
	 * @return bool True if lock was acquired, false otherwise.
	 */
	public function acquire( int $product_id, int $start_date, int $end_date, $staff_id = 0 ): bool {
		$lock_key = $this->generate_lock_key( $product_id, $start_date, $end_date, $staff_id );

		// Check if lock already exists.
		if ( get_transient( $lock_key ) ) {
			return false;
		}

		// Acquire lock with configurable TTL.
		$ttl = (int) apply_filters( 'woocommerce_appointments_booking_lock_ttl', self::DEFAULT_LOCK_TTL, null );
		return (bool) set_transient( $lock_key, time(), $ttl );
	}

	/**
	 * Acquire a booking lock with exponential backoff retry.
	 *
	 * Attempts to acquire a lock, retrying with exponential backoff if the
	 * initial attempt fails. This helps handle transient lock contention
	 * gracefully.
	 *
	 * @since 4.0.0
	 *
	 * @param int       $product_id Product ID.
	 * @param int       $start_date Start timestamp.
	 * @param int       $end_date   End timestamp.
	 * @param int|int[] $staff_id   Optional. Staff ID(s). Default 0.
	 * @param int       $max_retries Optional. Maximum number of retry attempts. Default 3.
	 *
	 * @return bool True if lock was acquired, false otherwise.
	 */
	public function acquire_with_retry( int $product_id, int $start_date, int $end_date, $staff_id = 0, int $max_retries = self::MAX_RETRIES ): bool {
		// Try initial acquisition.
		if ( $this->acquire( $product_id, $start_date, $end_date, $staff_id ) ) {
			return true;
		}

		// Retry with exponential backoff.
		for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
			$delay = $this->calculate_backoff_delay( $attempt );
			usleep( $delay );

			if ( $this->acquire( $product_id, $start_date, $end_date, $staff_id ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Release a booking lock.
	 *
	 * Removes the transient-based lock for a specific time slot.
	 *
	 * @since 4.0.0
	 *
	 * @param int       $product_id Product ID.
	 * @param int       $start_date Start timestamp.
	 * @param int       $end_date   End timestamp.
	 * @param int|int[] $staff_id   Optional. Staff ID(s). Default 0.
	 *
	 * @return void
	 */
	public function release( int $product_id, int $start_date, int $end_date, $staff_id = 0 ): void {
		$lock_key = $this->generate_lock_key( $product_id, $start_date, $end_date, $staff_id );
		delete_transient( $lock_key );
	}

	/**
	 * Generate a unique lock key for a booking slot.
	 *
	 * Creates a consistent lock key based on product ID, time slot, and staff.
	 * Uses rounded timestamps to reduce lock granularity and improve performance.
	 *
	 * @since 4.0.0
	 *
	 * @param int       $product_id Product ID.
	 * @param int       $start_date Start timestamp.
	 * @param int       $end_date   End timestamp.
	 * @param int|int[] $staff_id   Optional. Staff ID(s). Default 0.
	 *
	 * @return string Lock key.
	 */
	private function generate_lock_key( int $product_id, int $start_date, int $end_date, $staff_id = 0 ): string {
		// Normalize staff_id to string for consistent key generation.
		$staff_str = is_array( $staff_id ) ? implode( ',', array_map( 'intval', $staff_id ) ) : (string) intval( $staff_id );

		// Round timestamps to nearest minute to reduce lock granularity and improve performance.
		$start_rounded = (int) floor( $start_date / 60 ) * 60;
		$end_rounded   = (int) floor( $end_date / 60 ) * 60;

		return 'wc_appt_booking_lock_' . md5( $product_id . '|' . $start_rounded . '|' . $end_rounded . '|' . $staff_str );
	}

	/**
	 * Calculate exponential backoff delay for retry attempts.
	 *
	 * Calculates delay in microseconds using exponential backoff formula:
	 * delay = base_delay * (2 ^ (attempt - 1))
	 *
	 * @since 4.0.0
	 *
	 * @param int $attempt Current attempt number (1-based).
	 *
	 * @return int Delay in microseconds.
	 */
	private function calculate_backoff_delay( int $attempt ): int {
		$exponent = max( 0, $attempt - 1 );
		return (int) ( self::BASE_DELAY_MICROSECONDS * pow( 2, $exponent ) );
	}
}
