<?php
/**
 * Rental availability: date-range conflict queries with transient caching.
 *
 * @package WooEquipmentRental
 */

declare( strict_types=1 );

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

/**
 * Class WCRent_Rental_Availability
 *
 * Queries existing rental bookings and determines whether a product is
 * available for a requested date range.
 */
class WCRent_Rental_Availability {

	/**
	 * Returns the transient TTL in seconds from the admin setting (default 300).
	 */
	private static function cache_ttl(): int {
		return max( 0, (int) get_option( 'wcrent_cache_ttl', 300 ) );
	}

	// -----------------------------------------------------------------------
	// Cache invalidation hooks
	// -----------------------------------------------------------------------

	/**
	 * Registers hooks that clear the cache whenever an order changes.
	 */
	public static function register_cache_invalidation_hooks(): void {
		// woocommerce_new_order fires before line items are attached, making cache
		// invalidation premature and redundant. woocommerce_checkout_order_created
		// fires after items exist, which is the correct point to clear.
		add_action( 'woocommerce_order_status_changed', array( self::class, 'on_order_status_changed' ), 10, 4 );
		add_action( 'woocommerce_checkout_order_created', array( self::class, 'on_order_created' ) );
	}

	/**
	 * Clears availability transients for all products in a newly-created order.
	 *
	 * Uses per-product delete_transient() rather than a wildcard wp_options scan
	 * so persistent object caches (Redis, Memcached) are also invalidated.
	 *
	 * @param WC_Order $order Newly created order.
	 */
	public static function on_order_created( WC_Order $order ): void {
		self::clear_order_product_caches( $order );
	}

	/**
	 * Clears availability transients for all products in an order when
	 * the order status changes.
	 *
	 * @param int      $order_id   Order ID.
	 * @param string   $old_status Previous status slug.
	 * @param string   $new_status New status slug.
	 * @param WC_Order $order      Order object.
	 */
	public static function on_order_status_changed(
		int $order_id,
		string $old_status,
		string $new_status,
		WC_Order $order
	): void {
		self::clear_order_product_caches( $order );
	}

	/**
	 * Deletes ALL wcrent availability transients (nuclear option for new orders).
	 *
	 * Uses a wildcard delete via $wpdb because WordPress has no built-in
	 * wildcard transient deletion.
	 */
	public static function clear_all_availability_cache(): void {
		global $wpdb;

		// Fetch transient keys first so we can delete via delete_transient(), which
		// correctly purges persistent object caches (Redis, Memcached) in addition
		// to the wp_options table. A raw DELETE on wp_options misses object caches.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$option_names = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
				$wpdb->esc_like( '_transient_wcrent_avail_' ) . '%'
			)
		);
		// phpcs:enable

		foreach ( (array) $option_names as $option_name ) {
			$transient_key = str_replace( '_transient_', '', (string) $option_name );
			delete_transient( $transient_key );
		}
	}

	/**
	 * Clears the availability transient for a single product.
	 *
	 * @param int $product_id Product post ID.
	 */
	public static function clear_product_cache( int $product_id ): void {
		delete_transient( self::cache_key( $product_id ) );
	}

	// -----------------------------------------------------------------------
	// Public API
	// -----------------------------------------------------------------------

	/**
	 * Returns all booked date ranges for a product from active orders.
	 *
	 * Each entry in the returned array has the shape:
	 *   [ 'start' => 'YYYY-MM-DD', 'end' => 'YYYY-MM-DD', 'qty' => int ]
	 *
	 * Results are cached in a transient for {@see cache_ttl()} seconds.
	 *
	 * @param int $product_id Product post ID.
	 * @return array<int, array{start: string, end: string, qty: int}>
	 */
	public function get_booked_date_ranges( int $product_id ): array {
		$cache_key = self::cache_key( $product_id );
		$cached    = get_transient( $cache_key );

		if ( false !== $cached && is_array( $cached ) ) {
			return $cached;
		}

		$ranges = $this->query_booked_date_ranges( $product_id );

		set_transient( $cache_key, $ranges, self::cache_ttl() );

		return $ranges;
	}

	/**
	 * Checks whether a product is available for the requested date range and quantity.
	 *
	 * @param int    $product_id  Product post ID.
	 * @param string $start_date  Start date in YYYY-MM-DD format.
	 * @param string $end_date    End date in YYYY-MM-DD format.
	 * @param int    $qty_needed  Units needed (default 1).
	 * @return bool True when the product can be rented for the period.
	 */
	public function is_available(
		int $product_id,
		string $start_date,
		string $end_date,
		int $qty_needed = 1
	): bool {
		$booked = $this->get_booked_date_ranges( $product_id );
		return $this->fits_inventory( $product_id, $start_date, $end_date, $qty_needed, $booked );
	}

	/**
	 * Authoritative, uncached availability check for the order-creation backstop.
	 *
	 * Bypasses the transient cache and counts, besides all committed
	 * active-status bookings, the in-flight (pending) orders that completed
	 * this check earlier, identified by a lower booking sequence number.
	 * The sequence is assigned by {@see next_booking_seq()} while the caller
	 * holds {@see acquire_lock()} for the product, so sequence order equals
	 * check order and exactly one order wins a simultaneous last-unit race:
	 * the first checker sees no pending competitor; every later checker sees
	 * its committed sequence meta and is blocked. Order-creation commit order
	 * is irrelevant, which closes the race where a lower-ID order's creation
	 * transaction commits after a higher-ID order's check has already run.
	 *
	 * @param int    $product_id  Product post ID.
	 * @param string $start_date  Requested start (YYYY-MM-DD).
	 * @param string $end_date    Requested end (YYYY-MM-DD).
	 * @param int    $qty_needed  Units requested by the order being placed.
	 * @param int    $order_id    The order being placed (excluded from counting).
	 * @param int    $booking_seq This order's booking sequence number.
	 * @return bool True when the order fits within inventory.
	 */
	public function is_available_for_order(
		int $product_id,
		string $start_date,
		string $end_date,
		int $qty_needed,
		int $order_id,
		int $booking_seq
	): bool {
		$booked = $this->query_booked_date_ranges( $product_id, null, $order_id, $booking_seq );
		return $this->fits_inventory( $product_id, $start_date, $end_date, $qty_needed, $booked );
	}

	/**
	 * Returns the next value of the global booking sequence counter.
	 *
	 * The increment is a single atomic UPDATE (serialized by the row lock on
	 * the option row), so concurrent connections always receive distinct,
	 * monotonically increasing values even without the advisory lock.
	 *
	 * @return int
	 */
	public function next_booking_seq(): int {
		global $wpdb;

		if ( false === get_option( 'wcrent_booking_seq' ) ) {
			// Unique key on option_name makes concurrent seeding safe: the
			// losing INSERT fails silently and both proceed to the UPDATE.
			add_option( 'wcrent_booking_seq', '0', '', false );
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			"UPDATE {$wpdb->options} SET option_value = LAST_INSERT_ID(option_value + 1) WHERE option_name = 'wcrent_booking_seq'"
		);

		// $wpdb->insert_id is only populated for INSERT/REPLACE, so read the
		// connection-scoped LAST_INSERT_ID() set by the UPDATE explicitly.
		return (int) $wpdb->get_var( 'SELECT LAST_INSERT_ID()' );
		// phpcs:enable
	}

	/**
	 * How long a pending, unpaid order keeps holding its inventory in the
	 * order-creation backstop.
	 *
	 * Only needs to span the gap between two concurrent checkouts committing,
	 * not the shopper's whole gateway round-trip: once an order is paid it
	 * moves to an active status and is counted by the normal status branch.
	 *
	 * @return int Seconds.
	 */
	public static function pending_hold_seconds(): int {
		return max( 1, (int) apply_filters( 'wcrent_pending_hold_seconds', 300 ) );
	}

	/**
	 * Determines whether a requested range and quantity fit within a product's
	 * inventory given a set of already-booked ranges.
	 *
	 * @param int                                                     $product_id Product post ID.
	 * @param string                                                  $start_date Requested start (YYYY-MM-DD).
	 * @param string                                                  $end_date   Requested end (YYYY-MM-DD).
	 * @param int                                                     $qty_needed Units requested.
	 * @param array<int, array{start: string, end: string, qty: int}> $booked     Booked ranges to check against.
	 * @return bool
	 */
	private function fits_inventory(
		int $product_id,
		string $start_date,
		string $end_date,
		int $qty_needed,
		array $booked
	): bool {
		$inventory_qty = $this->inventory_qty( $product_id );

		$buffer_hours = (int) get_post_meta( $product_id, '_rental_buffer_hours', true );
		if ( 0 === $buffer_hours ) {
			$buffer_hours = (int) get_option( 'wcrent_default_buffer_hours', 0 );
		}

		// Apply buffer: widen the requested window by the buffer on both sides
		// so we check whether the buffer-padded period conflicts.
		try {
			$start_dt = new DateTimeImmutable( $start_date );
			$end_dt   = new DateTimeImmutable( $end_date );
		} catch ( \Exception $e ) {
			return false;
		}

		if ( $buffer_hours > 0 ) {
			$start_dt = $start_dt->modify( "-{$buffer_hours} hours" );
			$end_dt   = $end_dt->modify( "+{$buffer_hours} hours" );
		}

		$qty_booked = 0;

		// Under calendar_days billing (the default) the return day is billed and
		// occupied (see WCRent_Utils::calc_rental_days), so every range's end date
		// is inclusive: extend it +1 day and compare half-open. Under rental_nights
		// the end date is the free checkout day (exclusive), except same-day
		// point bookings, which still occupy their single day. The +1-day
		// extension keeps strict < / > correct for the buffer-hours edge case
		// where the padded window exactly touches a booking boundary.
		$inclusive_end = ( 'rental_nights' !== WCRent_Utils::billing_method() );

		foreach ( $booked as $range ) {
			try {
				$range_start = new DateTimeImmutable( $range['start'] );
				$range_end   = new DateTimeImmutable( $range['end'] );
			} catch ( \Exception $e ) {
				// Skip unparseable booked ranges rather than blocking availability.
				continue;
			}

			$range_end_cmp = ( $inclusive_end || $range_start->format( 'Y-m-d' ) === $range_end->format( 'Y-m-d' ) )
				? $range_end->modify( '+1 day' )
				: $range_end;
			$end_dt_cmp    = ( $inclusive_end || $start_dt->format( 'Y-m-d' ) === $end_dt->format( 'Y-m-d' ) )
				? $end_dt->modify( '+1 day' )
				: $end_dt;

			if ( $start_dt < $range_end_cmp && $end_dt_cmp > $range_start ) {
				$qty_booked += (int) ( $range['qty'] ?? 1 );
			}
		}

		return ( $qty_booked + $qty_needed ) <= $inventory_qty;
	}

	// -----------------------------------------------------------------------
	// Advisory locking (concurrent-checkout serialization)
	// -----------------------------------------------------------------------

	/**
	 * Acquires a MySQL advisory lock so concurrent checkouts for the same
	 * product serialize their final availability check + order creation.
	 *
	 * Blog-ID namespaced and clamped to MySQL's 64-char lock-name limit. The
	 * lock is connection-scoped and auto-released at the end of the request if
	 * {@see release_lock()} is not called.
	 *
	 * @param string $key     Lock discriminator (e.g. "book_123").
	 * @param int    $timeout Seconds to wait for the lock before giving up.
	 * @return bool True when the lock was acquired.
	 */
	public function acquire_lock( string $key, int $timeout = 10 ): bool {
		global $wpdb;
		$name = self::lock_name( $key );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$got = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $name, $timeout ) );
		return '1' === (string) $got;
	}

	/**
	 * Releases a lock previously taken with {@see acquire_lock()}.
	 *
	 * @param string $key Lock discriminator.
	 */
	public function release_lock( string $key ): void {
		global $wpdb;
		$name = self::lock_name( $key );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) );
	}

	/**
	 * Builds the blog-namespaced, length-clamped MySQL lock name.
	 *
	 * @param string $key Lock discriminator.
	 * @return string
	 */
	private static function lock_name( string $key ): string {
		return substr( 'wcrent_' . get_current_blog_id() . '_' . $key, 0, 64 );
	}

	/**
	 * Returns unavailable date ranges in the {from, to} shape expected by
	 * Flatpickr's disable option.
	 *
	 * For multi-unit products only dates where the booked quantity has reached
	 * the inventory count are disabled; partially-booked dates stay selectable
	 * and the REST availability check remains the authority.
	 *
	 * @param int $product_id Product post ID.
	 * @return array<int, array{from: string, to: string}>
	 */
	public function get_booked_ranges_for_js( int $product_id ): array {
		$ranges        = $this->get_booked_date_ranges( $product_id );
		$inventory_qty = $this->inventory_qty( $product_id );

		// Mirror fits_inventory(): under rental_nights the end date is the free
		// checkout day and must stay selectable, or back-to-back bookings (the
		// whole point of that billing mode) are impossible. Same-day point
		// bookings still occupy their single day.
		if ( 'rental_nights' === WCRent_Utils::billing_method() ) {
			foreach ( $ranges as $i => $range ) {
				if ( $range['start'] === $range['end'] ) {
					continue;
				}
				try {
					$ranges[ $i ]['end'] = ( new DateTimeImmutable( $range['end'] ) )
						->modify( '-1 day' )->format( 'Y-m-d' );
				} catch ( \Exception $e ) {
					continue;
				}
			}
		}

		if ( $inventory_qty <= 1 ) {
			return array_map(
				static fn( array $r ): array => array(
					'from' => $r['start'],
					'to'   => $r['end'],
				),
				$ranges
			);
		}

		// Sum booked quantity per day across all ranges (end date inclusive).
		$day_counts = array();
		foreach ( $ranges as $range ) {
			try {
				$day = new DateTimeImmutable( $range['start'] );
				$end = new DateTimeImmutable( $range['end'] );
			} catch ( \Exception $e ) {
				continue;
			}
			while ( $day <= $end ) {
				$key                = $day->format( 'Y-m-d' );
				$day_counts[ $key ] = ( $day_counts[ $key ] ?? 0 ) + (int) ( $range['qty'] ?? 1 );
				$day                = $day->modify( '+1 day' );
			}
		}

		$full_days = array_keys(
			array_filter( $day_counts, static fn( int $qty ): bool => $qty >= $inventory_qty )
		);
		sort( $full_days );

		// Merge consecutive fully-booked days into {from, to} ranges.
		$out      = array();
		$expected = '';
		foreach ( $full_days as $day ) {
			if ( $day === $expected ) {
				$out[ count( $out ) - 1 ]['to'] = $day;
			} else {
				$out[] = array(
					'from' => $day,
					'to'   => $day,
				);
			}
			$expected = ( new DateTimeImmutable( $day ) )->modify( '+1 day' )->format( 'Y-m-d' );
		}

		return $out;
	}

	/**
	 * Resolves the effective inventory quantity for a product, falling back to
	 * the global default when the product meta is unset.
	 *
	 * @param int $product_id Product post ID.
	 * @return int
	 */
	private function inventory_qty( int $product_id ): int {
		$inventory_qty = (int) get_post_meta( $product_id, '_rental_inventory_qty', true );
		if ( $inventory_qty < 1 ) {
			$inventory_qty = max( 1, (int) get_option( 'wcrent_default_inventory_qty', 1 ) );
		}
		return $inventory_qty;
	}

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

	/**
	 * Clears availability transients for every rental product line item in an order.
	 *
	 * @param WC_Order $order Order whose product caches to clear.
	 */
	private static function clear_order_product_caches( WC_Order $order ): void {
		foreach ( $order->get_items() as $item ) {
			/** @var WC_Order_Item_Product $item */
			$product_id = (int) $item->get_product_id();
			if ( $product_id ) {
				self::clear_product_cache( $product_id );
			}
		}
	}

	/**
	 * Builds the transient key for a given product.
	 *
	 * @param int $product_id Product post ID.
	 * @return string
	 */
	private static function cache_key( int $product_id ): string {
		// Include blog ID so product ID collisions don't occur across sites that
		// share a persistent object cache (Redis/Memcached) in multisite.
		return 'wcrent_avail_' . get_current_blog_id() . '_' . $product_id;
	}

	/**
	 * Queries the database for booked date ranges.
	 *
	 * Joins wc_order_items -> wc_order_itemmeta to find line items that
	 * belong to active orders and carry _rental_start / _rental_end meta.
	 *
	 * This direct $wpdb query is intentional: wc_get_orders() does not support
	 * line item meta queries, and the WooCommerce HPOS tables are the canonical
	 * source for this data.
	 *
	 * @param int                    $product_id       Product post ID.
	 * @param array<int,string>|null $statuses         Order statuses to include (default: active statuses).
	 * @param int                    $exclude_order_id Backstop mode: the order being placed, excluded from counting (0 = off).
	 * @param int                    $before_seq       Backstop mode: also count pending orders whose booking sequence is lower.
	 * @return array<int, array{start: string, end: string, qty: int}>
	 */
	private function query_booked_date_ranges(
		int $product_id,
		?array $statuses = null,
		int $exclude_order_id = 0,
		int $before_seq = 0
	): array {
		global $wpdb;

		$active_statuses       = ( null === $statuses ) ? WCRent_Utils::active_statuses() : array_values( $statuses );
		$statuses_placeholders = WCRent_Utils::status_placeholders( $active_statuses );

		$order_info    = WCRent_Utils::orders_table();
		$orders_table  = $order_info['table'];
		$status_column = $order_info['status_col'];

		// Backstop mode: besides committed active-status bookings, count
		// in-flight pending orders that completed their availability check
		// earlier (lower booking sequence, assigned under the advisory lock).
		// Sequence order equals check order, so exactly one order wins a
		// simultaneous last-unit race regardless of creation commit order.
		$seq_join      = '';
		$status_clause = "{$status_column} IN ( $statuses_placeholders )";
		$prepare_args  = array_merge( array( $product_id ), $active_statuses );

		if ( $exclude_order_id > 0 ) {
			$meta_info = WCRent_Utils::orders_meta_table();
			$seq_join  = "LEFT JOIN {$meta_info['table']} AS om_seq
				ON om_seq.{$meta_info['fk_col']} = o.id
				AND om_seq.meta_key = '_wcrent_booking_seq'";

			// Bound the pending branch to the race window it exists to close. An
			// unbounded one lets an order abandoned at the payment gateway hold
			// its sequence forever: every later order draws a higher sequence, so
			// the dates would stay unsellable and each attempt would create then
			// force-cancel a legitimate order.
			$date_column   = $order_info['date_col'];
			$status_clause = "o.id <> %d AND ( {$status_column} IN ( $statuses_placeholders )
				OR ( {$status_column} = 'wc-pending'
					AND om_seq.meta_value IS NOT NULL
					AND CAST( om_seq.meta_value AS UNSIGNED ) < %d
					AND {$date_column} >= %s ) )";
			$prepare_args  = array_merge(
				array( $product_id, $exclude_order_id ),
				$active_statuses,
				array( $before_seq, gmdate( 'Y-m-d H:i:s', time() - self::pending_hold_seconds() ) )
			);
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
		$query = $wpdb->prepare(
			"
			SELECT
				im_start.meta_value  AS rental_start,
				im_end.meta_value    AS rental_end,
				oi.order_item_id,
				im_qty.meta_value    AS qty
			FROM {$wpdb->prefix}woocommerce_order_items AS oi
			INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS im_pid
				ON im_pid.order_item_id = oi.order_item_id
				AND im_pid.meta_key = '_product_id'
				AND im_pid.meta_value = %d
			INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS im_start
				ON im_start.order_item_id = oi.order_item_id
				AND im_start.meta_key = '_rental_start'
			INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS im_end
				ON im_end.order_item_id = oi.order_item_id
				AND im_end.meta_key = '_rental_end'
			LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS im_qty
				ON im_qty.order_item_id = oi.order_item_id
				AND im_qty.meta_key = '_qty'
			INNER JOIN {$orders_table} AS o
				ON o.id = oi.order_id
			{$seq_join}
			WHERE oi.order_item_type = 'line_item'
			  AND {$status_clause}
			",
			$prepare_args
		);
		// phpcs:enable

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $orders_table/$status_column are built from $wpdb->prefix + hardcoded string; $query is the output of $wpdb->prepare()
		$rows   = $wpdb->get_results( $query );
		$ranges = array();

		if ( ! $rows ) {
			return $ranges;
		}

		foreach ( $rows as $row ) {
			$start = sanitize_text_field( $row->rental_start );
			$end   = sanitize_text_field( $row->rental_end );

			if ( ! $start || ! $end ) {
				continue;
			}

			// Use WCRent_Utils::is_valid_date() which also runs checkdate() for
			// full calendar validity, not just format matching.
			if ( ! WCRent_Utils::is_valid_date( $start ) || ! WCRent_Utils::is_valid_date( $end ) ) {
				continue;
			}

			$ranges[] = array(
				'start' => $start,
				'end'   => $end,
				// (int) null === 0 so max(1, ...) already handles missing im_qty rows
				// from the LEFT JOIN — no additional NULL check needed.
				'qty'   => max( 1, (int) $row->qty ),
			);
		}

		return $ranges;
	}
}

// Intentionally not calling register_cache_invalidation_hooks() here at file scope.
// It is called from wcrent_init() after WooCommerce availability is confirmed.
