<?php
/**
 * Rental cart: captures start/end date from POST, attaches them to the cart
 * item, restores from session, and prevents duplicate-date merging.
 *
 * @package WooEquipmentRental
 */

declare( strict_types=1 );

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class WCRent_Rental_Cart
 */
class WCRent_Rental_Cart {

	// -----------------------------------------------------------------------
	// Initialisation
	// -----------------------------------------------------------------------

	/**
	 * Registers all hooks.
	 */
	public static function init(): void {
		add_filter(
			'woocommerce_add_to_cart_validation',
			array( self::class, 'validate_rental_dates' ),
			10,
			3
		);

		add_filter(
			'woocommerce_add_cart_item_data',
			array( self::class, 'add_cart_item_data' ),
			10,
			3
		);

		add_filter(
			'woocommerce_get_cart_item_from_session',
			array( self::class, 'get_cart_item_from_session' ),
			10,
			3
		);

		add_filter(
			'woocommerce_get_item_data',
			array( self::class, 'display_cart_item_data' ),
			10,
			2
		);

		add_filter(
			'woocommerce_add_cart_item_data',
			array( self::class, 'prevent_item_merging' ),
			20,
			3
		);

		add_action(
			'woocommerce_checkout_process',
			array( self::class, 'validate_cart_rental_availability' )
		);

		// The classic hook above never fires on the block-based (Store API)
		// checkout, so register the Store API cart-error hook too. Without this,
		// the default WooCommerce checkout would skip the final availability
		// re-check and allow a double-booking.
		add_action(
			'woocommerce_store_api_cart_errors',
			array( self::class, 'add_store_api_availability_errors' ),
			10,
			2
		);

		// Authoritative post-creation backstop under an advisory lock. The
		// pre-creation checks above catch the common case; these close the
		// remaining window where two checkouts for the last unit both pass
		// validation and create orders in the same instant. Both fire after the
		// order is created but before payment is processed.
		add_action(
			'woocommerce_checkout_order_processed',
			array( self::class, 'enforce_availability_on_classic_order' ),
			10,
			1
		);
		add_action(
			'woocommerce_store_api_checkout_order_processed',
			array( self::class, 'enforce_availability_on_blocks_order' ),
			10,
			1
		);
	}

	// -----------------------------------------------------------------------
	// Validation
	// -----------------------------------------------------------------------

	/**
	 * Re-checks availability for every rental item in the cart at the classic
	 * (shortcode) checkout. Prevents a race where items become unavailable
	 * between add-to-cart and checkout.
	 */
	public static function validate_cart_rental_availability(): void {
		foreach ( self::get_unavailable_cart_item_names() as $name ) {
			wc_add_notice(
				sprintf(
					/* translators: %s: product name */
					__( '"%s" is no longer available for the selected rental dates. Please remove it and choose different dates.', 'harbor-equipment-rental-for-woocommerce' ),
					$name
				),
				'error'
			);
		}
	}

	/**
	 * Store API equivalent of {@see validate_cart_rental_availability()} for the
	 * block-based checkout. Adds errors to the shared WP_Error so the Store API
	 * blocks the order from being placed.
	 *
	 * @param WP_Error $errors Accumulating cart errors.
	 * @param WC_Cart  $cart   The cart being validated (unused; we read WC()->cart).
	 */
	public static function add_store_api_availability_errors( $errors, $cart ): void {
		unset( $cart );
		if ( ! $errors instanceof WP_Error ) {
			return;
		}

		foreach ( self::get_unavailable_cart_item_names() as $name ) {
			$errors->add(
				'wcrent_unavailable',
				sprintf(
					/* translators: %s: product name */
					__( '"%s" is no longer available for the selected rental dates. Please remove it and choose different dates.', 'harbor-equipment-rental-for-woocommerce' ),
					$name
				)
			);
		}
	}

	/**
	 * Classic-checkout backstop: fires on `woocommerce_checkout_order_processed`
	 * after the order is created but before payment. Cancels and rejects the
	 * order if a concurrent booking claimed the last unit.
	 *
	 * @param int $order_id Newly created order ID.
	 * @throws Exception When the order lost a concurrent-booking race.
	 */
	public static function enforce_availability_on_classic_order( int $order_id ): void {
		$order = wc_get_order( $order_id );
		if ( ! $order instanceof WC_Order ) {
			return;
		}

		$conflicts = self::run_order_availability_backstop( $order );
		if ( empty( $conflicts ) ) {
			return;
		}

		self::cancel_overbooked_order( $order, $conflicts );
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is a translated literal with tag-stripped product names; WooCommerce escapes the notice on output.
		throw new Exception( self::overbook_message( $conflicts ) );
	}

	/**
	 * Store API (block checkout) backstop: fires on
	 * `woocommerce_store_api_checkout_order_processed`, before payment.
	 *
	 * @param WC_Order $order Newly created order.
	 * @throws \Automattic\WooCommerce\StoreApi\Exceptions\RouteException When the order lost a race.
	 * @throws Exception Fallback when the Store API exception class is unavailable.
	 */
	public static function enforce_availability_on_blocks_order( $order ): void {
		if ( ! $order instanceof WC_Order ) {
			return;
		}

		$conflicts = self::run_order_availability_backstop( $order );
		if ( empty( $conflicts ) ) {
			return;
		}

		self::cancel_overbooked_order( $order, $conflicts );

		$message = self::overbook_message( $conflicts );
		// The message is a translated literal interpolated only with product
		// names that have already been tag-stripped. It is surfaced as a JSON
		// error (Store API) or an HTML notice (fallback), where esc_html would
		// corrupt the JSON, so escaping is intentionally omitted here.
		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		if ( class_exists( '\Automattic\WooCommerce\StoreApi\Exceptions\RouteException' ) ) {
			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
				'wcrent_unavailable',
				$message,
				409
			);
		}
		throw new Exception( $message );
		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
	}

	/**
	 * Runs the authoritative, uncached availability check for each rental line
	 * item of an order while holding a per-product advisory lock, so concurrent
	 * checkouts serialize. Returns the names of items that lost the race.
	 *
	 * Correctness note: the guarantee that at most one order wins a last-unit
	 * race comes from the booking sequence assigned under the advisory lock:
	 * each order commits its sequence meta before checking, and
	 * {@see WCRent_Rental_Availability::is_available_for_order()} counts
	 * pending competitors with a lower sequence (plus all committed
	 * active-status bookings). Sequence order equals check order, so the first
	 * checker wins and every later checker is blocked, regardless of the order
	 * in which the creation transactions committed (order IDs are assigned at
	 * INSERT time but can commit out of order, so an ID tiebreak is unsound).
	 * On multi-writer topologies (Galera, Aurora multi-writer) the advisory
	 * lock is not globally coordinated and the guarantee degrades to
	 * best-effort; operators on such clusters should enable causal reads
	 * (e.g. wsrep_sync_wait) or route writes to one primary. The lock can be
	 * tuned or disabled via the wcrent_use_booking_lock and
	 * wcrent_booking_lock_timeout filters.
	 *
	 * @param WC_Order $order Order to validate.
	 * @return array<int, string> Conflicting item names (empty when all fit).
	 */
	private static function run_order_availability_backstop( WC_Order $order ): array {
		if ( 'yes' !== get_option( 'wcrent_enable_availability_check', 'yes' ) ) {
			return array();
		}

		// Collect the rental line items up front. Names are tag-stripped so they
		// are safe in both an HTML notice and a JSON (Store API) error response.
		$rental_items = array();
		foreach ( $order->get_items() as $item ) {
			if ( ! $item instanceof WC_Order_Item_Product ) {
				continue;
			}
			$product_id = (int) $item->get_product_id();
			$start      = (string) $item->get_meta( '_rental_start' );
			$end        = (string) $item->get_meta( '_rental_end' );
			if ( ! $product_id || '' === $start || '' === $end ) {
				continue;
			}
			$rental_items[] = array(
				'product_id' => $product_id,
				'start'      => $start,
				'end'        => $end,
				'qty'        => max( 1, (int) $item->get_quantity() ),
				'name'       => wp_strip_all_tags( (string) $item->get_name() ),
			);
		}

		if ( empty( $rental_items ) ) {
			return array();
		}

		// Acquire one advisory lock per product in ascending product-ID order so
		// two multi-product orders can never deadlock on inverse lock ordering.
		$product_ids = array_values( array_unique( array_map( static fn( array $i ): int => $i['product_id'], $rental_items ) ) );
		sort( $product_ids );

		$order_id     = (int) $order->get_id();
		$availability = new WCRent_Rental_Availability();
		$conflicts    = array();

		$use_lock    = (bool) apply_filters( 'wcrent_use_booking_lock', true );
		$lock_wait   = max( 0, (int) apply_filters( 'wcrent_booking_lock_timeout', 10 ) );
		$locked_pids = array();

		if ( $use_lock ) {
			foreach ( $product_ids as $pid ) {
				$locked_pids[] = $pid;
				if ( ! $availability->acquire_lock( 'book_' . $pid, $lock_wait ) ) {
					// Could not serialize (busy/timeout, or an uncoordinated lock
					// on a multi-writer cluster). The order-ID tiebreak still
					// guards against overbooking; record it for observability.
					wc_get_logger()->warning(
						sprintf( 'WCRent: booking lock for product %d not acquired within %ds; relying on order-ID tiebreak.', $pid, $lock_wait ),
						array( 'source' => 'woo-equipment-rental' )
					);
				}
			}
		}

		try {
			// Assign and persist this order's booking sequence while holding the
			// lock, BEFORE checking: competitors that check later must see it.
			$booking_seq = $availability->next_booking_seq();
			$order->update_meta_data( '_wcrent_booking_seq', (string) $booking_seq );
			$order->save_meta_data();

			foreach ( $rental_items as $ri ) {
				if ( ! $availability->is_available_for_order( $ri['product_id'], $ri['start'], $ri['end'], $ri['qty'], $order_id, $booking_seq ) ) {
					$conflicts[ $ri['name'] ] = $ri['name'];
				}
			}
		} finally {
			foreach ( $locked_pids as $pid ) {
				$availability->release_lock( 'book_' . $pid );
			}
		}

		return array_values( $conflicts );
	}

	/**
	 * Cancels an order that lost a concurrent-booking race, freeing it from
	 * availability and leaving an audit note.
	 *
	 * @param WC_Order           $order     Order to cancel.
	 * @param array<int, string> $conflicts Conflicting item names.
	 */
	private static function cancel_overbooked_order( WC_Order $order, array $conflicts ): void {
		$order->update_status(
			'cancelled',
			sprintf(
				/* translators: %s: comma-separated product names */
				__( 'Rental became unavailable during checkout (concurrent booking of %s). Order cancelled to prevent a double-booking.', 'harbor-equipment-rental-for-woocommerce' ),
				implode( ', ', $conflicts )
			)
		);
	}

	/**
	 * Builds the customer-facing message for a lost concurrent-booking race.
	 *
	 * @param array<int, string> $conflicts Conflicting item names.
	 * @return string
	 */
	private static function overbook_message( array $conflicts ): string {
		return sprintf(
			/* translators: %s: quoted, comma-separated product names */
			__( 'Sorry, "%s" was just booked by another customer for the selected dates. Your order was not completed — please choose different dates.', 'harbor-equipment-rental-for-woocommerce' ),
			implode( '", "', $conflicts )
		);
	}

	/**
	 * Returns the display names of rental cart items that can no longer be
	 * fulfilled for their selected dates. Aggregates quantity across sibling
	 * cart lines for the same product whose date ranges overlap, so two separate
	 * lines for the same unit cannot together exceed inventory (intra-cart
	 * overbooking).
	 *
	 * @return array<int, string> Unique product names, empty when all available.
	 */
	private static function get_unavailable_cart_item_names(): array {
		if ( 'yes' !== get_option( 'wcrent_enable_availability_check', 'yes' ) ) {
			return array();
		}

		$cart = WC()->cart;
		if ( ! $cart ) {
			return array();
		}

		// Collect rental line items with their requested quantities.
		$items = array();
		foreach ( $cart->get_cart() as $cart_item ) {
			if ( empty( $cart_item['rental_start'] ) || empty( $cart_item['rental_end'] ) ) {
				continue;
			}
			$items[] = array(
				'product_id' => (int) $cart_item['data']->get_id(),
				'start'      => (string) $cart_item['rental_start'],
				'end'        => (string) $cart_item['rental_end'],
				'qty'        => max( 1, (int) ( $cart_item['quantity'] ?? 1 ) ),
				'name'       => (string) $cart_item['data']->get_name(),
			);
		}

		$availability = new WCRent_Rental_Availability();
		$unavailable  = array();

		foreach ( $items as $item ) {
			// Sum the quantity of every sibling line for the same product whose
			// range overlaps this one (inclusive Y-m-d comparison), including
			// this line itself, so the combined demand is validated at once.
			$combined_qty = 0;
			foreach ( $items as $sibling ) {
				if ( $sibling['product_id'] !== $item['product_id'] ) {
					continue;
				}
				if ( $item['start'] <= $sibling['end'] && $sibling['start'] <= $item['end'] ) {
					$combined_qty += $sibling['qty'];
				}
			}

			if ( ! $availability->is_available( $item['product_id'], $item['start'], $item['end'], $combined_qty ) ) {
				$unavailable[ $item['name'] ] = $item['name'];
			}
		}

		return array_values( $unavailable );
	}

	/**
	 * Validates rental dates when adding a rental product to the cart.
	 *
	 * @param bool $passed     Whether validation passed so far.
	 * @param int  $product_id Product post ID.
	 * @param int  $quantity   Quantity being added.
	 * @return bool
	 */
	public static function validate_rental_dates(
		bool $passed,
		int $product_id,
		int $quantity
	): bool {
		if ( ! WCRent_Utils::is_rental_product( $product_id ) ) {
			return $passed;
		}

		$nonce = isset( $_POST['wcrent_nonce'] )
			? sanitize_text_field( wp_unslash( $_POST['wcrent_nonce'] ) )
			: '';
		if ( ! wp_verify_nonce( $nonce, 'wcrent_add_rental' ) ) {
			wc_add_notice(
				__( 'Please select rental dates before adding to cart.', 'harbor-equipment-rental-for-woocommerce' ),
				'error'
			);
			return false;
		}

		$dates     = self::get_posted_rental_dates();
		$start_raw = $dates['start'];
		$end_raw   = $dates['end'];

		if ( ! $start_raw || ! $end_raw ) {
			wc_add_notice(
				__( 'Please select rental start and end dates before adding to cart.', 'harbor-equipment-rental-for-woocommerce' ),
				'error'
			);
			return false;
		}

		if ( ! WCRent_Utils::is_valid_date( $start_raw ) || ! WCRent_Utils::is_valid_date( $end_raw ) ) {
			wc_add_notice(
				__( 'Invalid rental date. Please select valid start and end dates.', 'harbor-equipment-rental-for-woocommerce' ),
				'error'
			);
			return false;
		}

		// WordPress forces PHP's default timezone to UTC, so an unqualified
		// 'today' rolls over before the store's own midnight and rejects
		// same-day bookings the picker (which uses wp_date) still offers.
		$tz    = wp_timezone();
		$start = new DateTimeImmutable( $start_raw, $tz );
		$end   = new DateTimeImmutable( $end_raw, $tz );
		$today = new DateTimeImmutable( 'today', $tz );

		if ( $start < $today ) {
			wc_add_notice(
				__( 'The rental start date cannot be in the past.', 'harbor-equipment-rental-for-woocommerce' ),
				'error'
			);
			return false;
		}

		// End must not be before start (same-day is valid for calendar_days billing).
		if ( $end < $start ) {
			wc_add_notice(
				__( 'The rental end date must not be before the start date.', 'harbor-equipment-rental-for-woocommerce' ),
				'error'
			);
			return false;
		}

		$min_days    = (int) get_post_meta( $product_id, '_rental_min_days', true );
		$rental_days = WCRent_Utils::calc_rental_days( $start_raw, $end_raw );

		if ( $min_days > 0 && $rental_days < $min_days ) {
			wc_add_notice(
				sprintf(
					/* translators: %d: minimum rental days */
					_n(
						'Minimum rental period is %d day.',
						'Minimum rental period is %d days.',
						$min_days,
						'harbor-equipment-rental-for-woocommerce'
					),
					$min_days
				),
				'error'
			);
			return false;
		}

		$max_days = (int) get_post_meta( $product_id, '_rental_max_days', true );

		if ( $max_days > 0 && $rental_days > $max_days ) {
			wc_add_notice(
				sprintf(
					/* translators: %d: maximum rental days */
					_n(
						'Maximum rental period is %d day.',
						'Maximum rental period is %d days.',
						$max_days,
						'harbor-equipment-rental-for-woocommerce'
					),
					$max_days
				),
				'error'
			);
			return false;
		}

		$price_hour = (float) get_post_meta( $product_id, '_rental_price_hour', true );
		if ( $price_hour > 0 && $start_raw === $end_raw ) {
			$start_time = $dates['start_time'];
			$end_time   = $dates['end_time'];

			if ( ! $start_time || ! $end_time ) {
				wc_add_notice(
					__( 'Please select start and end times for a same-day rental.', 'harbor-equipment-rental-for-woocommerce' ),
					'error'
				);
				return false;
			}

			// Require strict HH:MM format — strtotime() accepts relative expressions
			// like "2 seconds" which would let an attacker manipulate the price.
			if ( ! preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $start_time ) || ! preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $end_time ) ) {
				wc_add_notice(
					__( 'Invalid time format. Please select valid start and end times.', 'harbor-equipment-rental-for-woocommerce' ),
					'error'
				);
				return false;
			}

			// Compare as HH:MM strings — lexicographic comparison is valid for time.
			if ( $end_time <= $start_time ) {
				wc_add_notice(
					__( 'End time must be after start time.', 'harbor-equipment-rental-for-woocommerce' ),
					'error'
				);
				return false;
			}
		}

		if ( 'yes' === get_option( 'wcrent_enable_availability_check', 'yes' ) ) {
			$availability = new WCRent_Rental_Availability();
			if ( ! $availability->is_available( $product_id, $start_raw, $end_raw, $quantity ) ) {
				wc_add_notice(
					__( 'Sorry, this product is not available for the selected dates. Please choose different dates.', 'harbor-equipment-rental-for-woocommerce' ),
					'error'
				);
				return false;
			}
		}

		return $passed;
	}

	// -----------------------------------------------------------------------
	// Cart item data
	// -----------------------------------------------------------------------

	/**
	 * Adds rental date data to the cart item array.
	 *
	 * @param array<string, mixed> $cart_item_data Existing cart item data.
	 * @param int                  $product_id     Product post ID.
	 * @param int                  $variation_id   Variation ID (0 if not variable).
	 * @return array<string, mixed>
	 */
	public static function add_cart_item_data(
		array $cart_item_data,
		int $product_id,
		int $variation_id
	): array {
		if ( ! WCRent_Utils::is_rental_product( $product_id ) ) {
			return $cart_item_data;
		}

		$nonce = isset( $_POST['wcrent_nonce'] )
			? sanitize_text_field( wp_unslash( $_POST['wcrent_nonce'] ) )
			: '';
		if ( ! wp_verify_nonce( $nonce, 'wcrent_add_rental' ) ) {
			return $cart_item_data;
		}

		$dates     = self::get_posted_rental_dates();
		$start_raw = $dates['start'];
		$end_raw   = $dates['end'];

		if ( ! $start_raw || ! $end_raw ) {
			return $cart_item_data;
		}

		if ( ! WCRent_Utils::is_valid_date( $start_raw ) || ! WCRent_Utils::is_valid_date( $end_raw ) ) {
			return $cart_item_data;
		}

		// Always recalculate server-side; never trust the POST value.
		$rental_days = WCRent_Utils::calc_rental_days( $start_raw, $end_raw );

		if ( 0 === $rental_days ) {
			return $cart_item_data;
		}

		$cart_item_data['rental_start'] = $start_raw;
		$cart_item_data['rental_end']   = $end_raw;
		$cart_item_data['rental_days']  = $rental_days;

		$price_hour = (float) get_post_meta( $product_id, '_rental_price_hour', true );
		if ( $price_hour > 0 && $start_raw === $end_raw ) {
			$start_time = $dates['start_time'];
			$end_time   = $dates['end_time'];

			if ( $start_time && $end_time
			&& preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $start_time )
			&& preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $end_time )
			&& $end_time > $start_time ) {
				// Calculate hours server-side — never trust POST value for this.
				$start_ts = strtotime( "$start_raw $start_time" );
				$end_ts   = strtotime( "$end_raw $end_time" );
				if ( false === $start_ts || false === $end_ts ) {
					return $cart_item_data;
				}
				$hours = ( $end_ts - $start_ts ) / HOUR_IN_SECONDS;

				$cart_item_data['rental_start_time'] = $start_time;
				$cart_item_data['rental_end_time']   = $end_time;
				$cart_item_data['rental_hours']      = round( $hours, 2 );
			}
		}

		return $cart_item_data;
	}

	// -----------------------------------------------------------------------
	// Session restoration
	// -----------------------------------------------------------------------

	/**
	 * Restores rental data from the WooCommerce session into the cart item.
	 *
	 * @param array<string, mixed> $cart_item     Cart item data.
	 * @param array<string, mixed> $values        Values from session.
	 * @param string               $cart_item_key Cart item hash.
	 * @return array<string, mixed>
	 */
	public static function get_cart_item_from_session(
		array $cart_item,
		array $values,
		string $cart_item_key
	): array {
		if ( ! empty( $values['rental_start'] ) ) {
			$restored = sanitize_text_field( $values['rental_start'] );
			if ( WCRent_Utils::is_valid_date( $restored ) ) {
				$cart_item['rental_start'] = $restored;
			} else {
				wc_get_logger()->warning(
					sprintf( 'WCRent: invalid rental_start "%s" discarded from session (key %s)', $restored, $cart_item_key ),
					array( 'source' => 'harbor-equipment-rental-for-woocommerce' )
				);
			}
		}

		if ( ! empty( $values['rental_end'] ) ) {
			$restored = sanitize_text_field( $values['rental_end'] );
			if ( WCRent_Utils::is_valid_date( $restored ) ) {
				$cart_item['rental_end'] = $restored;
			} else {
				wc_get_logger()->warning(
					sprintf( 'WCRent: invalid rental_end "%s" discarded from session (key %s)', $restored, $cart_item_key ),
					array( 'source' => 'harbor-equipment-rental-for-woocommerce' )
				);
			}
		}

		if ( ! empty( $cart_item['rental_start'] ) && ! empty( $cart_item['rental_end'] ) ) {
			$derived = WCRent_Utils::calc_rental_days( $cart_item['rental_start'], $cart_item['rental_end'] );
			if ( $derived > 0 ) {
				$cart_item['rental_days'] = $derived;
			}
		} elseif ( isset( $values['rental_days'] ) ) {
			$cart_item['rental_days'] = absint( $values['rental_days'] );
		}

		$time_regex = '/^([01]\d|2[0-3]):[0-5]\d$/';

		if ( ! empty( $values['rental_start_time'] ) ) {
			$t = sanitize_text_field( $values['rental_start_time'] );
			if ( preg_match( $time_regex, $t ) ) {
				$cart_item['rental_start_time'] = $t;
			}
		}
		if ( ! empty( $values['rental_end_time'] ) ) {
			$t = sanitize_text_field( $values['rental_end_time'] );
			if ( preg_match( $time_regex, $t ) ) {
				$cart_item['rental_end_time'] = $t;
			}
		}
		if ( isset( $values['rental_hours'] )
			&& ! empty( $cart_item['rental_start'] )
			&& ! empty( $cart_item['rental_start_time'] )
			&& ! empty( $cart_item['rental_end_time'] ) ) {
			// Re-derive server-side from validated times rather than trusting the stored float.
			$ts_s = strtotime( $cart_item['rental_start'] . ' ' . $cart_item['rental_start_time'] );
			$ts_e = strtotime( $cart_item['rental_start'] . ' ' . $cart_item['rental_end_time'] );
			if ( false !== $ts_s && false !== $ts_e && $ts_e > $ts_s ) {
				$cart_item['rental_hours'] = round( ( $ts_e - $ts_s ) / HOUR_IN_SECONDS, 2 );
			}
		}

		return $cart_item;
	}

	// -----------------------------------------------------------------------
	// Display
	// -----------------------------------------------------------------------

	/**
	 * Adds rental date display data to the cart item meta (shown below the
	 * product name in the cart and checkout tables).
	 *
	 * @param array<int, array{name: string, value: string}> $item_data  Existing display data.
	 * @param array<string, mixed>                           $cart_item  Cart item.
	 * @return array<int, array{name: string, value: string}>
	 */
	public static function display_cart_item_data(
		array $item_data,
		array $cart_item
	): array {
		if ( empty( $cart_item['rental_start'] ) || empty( $cart_item['rental_end'] ) ) {
			return $item_data;
		}

		$start_ts = strtotime( $cart_item['rental_start'] );
		$end_ts   = strtotime( $cart_item['rental_end'] );

		$item_data[] = array(
			'name'  => __( 'Rental start', 'harbor-equipment-rental-for-woocommerce' ),
			'value' => $start_ts
				? esc_html( wp_date( get_option( 'date_format' ), $start_ts ) )
				: esc_html( $cart_item['rental_start'] ),
		);

		$item_data[] = array(
			'name'  => __( 'Rental end', 'harbor-equipment-rental-for-woocommerce' ),
			'value' => $end_ts
				? esc_html( wp_date( get_option( 'date_format' ), $end_ts ) )
				: esc_html( $cart_item['rental_end'] ),
		);

		$item_data[] = array(
			'name'  => __( 'Rental days', 'harbor-equipment-rental-for-woocommerce' ),
			'value' => esc_html( (string) ( $cart_item['rental_days'] ?? 0 ) ),
		);

		if ( ! empty( $cart_item['rental_start_time'] ) && ! empty( $cart_item['rental_end_time'] ) ) {
			$item_data[] = array(
				'name'  => __( 'Rental time', 'harbor-equipment-rental-for-woocommerce' ),
				'value' => esc_html( $cart_item['rental_start_time'] . ' – ' . $cart_item['rental_end_time'] ),
			);
		}

		return $item_data;
	}

	// -----------------------------------------------------------------------
	// Prevent merging
	// -----------------------------------------------------------------------

	/**
	 * Appends a unique key to cart item data so that WooCommerce does not
	 * merge two cart items for the same product with different rental dates.
	 *
	 * This runs at priority 20 (after add_cart_item_data at 10) so the
	 * rental_start/end are already set.
	 *
	 * @param array<string, mixed> $cart_item_data Existing cart item data.
	 * @param int                  $product_id     Product post ID.
	 * @param int                  $variation_id   Variation ID.
	 * @return array<string, mixed>
	 */
	public static function prevent_item_merging(
		array $cart_item_data,
		int $product_id,
		int $variation_id
	): array {
		if ( empty( $cart_item_data['rental_start'] ) ) {
			return $cart_item_data;
		}

		// wp_hash() is preferred over md5() — uses HMAC-SHA256 with the site
		// secret key so the value cannot be trivially forged.
		$cart_item_data['wcrent_unique_key'] = wp_hash(
			$cart_item_data['rental_start']
			. $cart_item_data['rental_end']
			. ( $cart_item_data['rental_start_time'] ?? '' )
			. ( $cart_item_data['rental_end_time'] ?? '' )
			. $product_id
		);

		return $cart_item_data;
	}

	// -----------------------------------------------------------------------
	// Private helpers
	// -----------------------------------------------------------------------

	/**
	 * Reads and sanitizes rental_start / rental_end from $_POST.
	 *
	 * Returns an array with keys 'start', 'end', 'start_time', and 'end_time'
	 * (empty strings if absent). Centralises the duplicated $_POST read that
	 * previously appeared in both validate_rental_dates() and add_cart_item_data().
	 *
	 * @return array{start: string, end: string, start_time: string, end_time: string}
	 */
	private static function get_posted_rental_dates(): array {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by callers before this helper is called
		$start      = isset( $_POST['rental_start'] )
			? sanitize_text_field( wp_unslash( $_POST['rental_start'] ) )
			: '';
		$end        = isset( $_POST['rental_end'] )
			? sanitize_text_field( wp_unslash( $_POST['rental_end'] ) )
			: '';
		$start_time = isset( $_POST['rental_start_time'] )
			? sanitize_text_field( wp_unslash( $_POST['rental_start_time'] ) )
			: '';
		$end_time   = isset( $_POST['rental_end_time'] )
			? sanitize_text_field( wp_unslash( $_POST['rental_end_time'] ) )
			: '';
		// phpcs:enable

		return array(
			'start'      => $start,
			'end'        => $end,
			'start_time' => $start_time,
			'end_time'   => $end_time,
		);
	}
}
