<?php
/**
 * Admin booking reschedule flow.
 *
 * Renders an availability-aware calendar in the booking details screen so
 * staff can move an existing booking to a new date/time (and resource) without
 * touching the order or its cost.
 *
 * @package WooCommerce Bookings
 */

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

/**
 * WC_Bookings_Reschedule class.
 */
class WC_Bookings_Reschedule {

	/**
	 * Singleton instance.
	 *
	 * @var WC_Bookings_Reschedule|null
	 */
	private static $instance = null;

	/**
	 * The booking currently being rendered, if any. Used by filter callbacks.
	 *
	 * @var WC_Booking|null
	 */
	private $current_booking = null;

	/**
	 * Get / lazily create the shared instance.
	 *
	 * The constructor registers hooks; using a singleton prevents
	 * double-registration when more than one caller needs access (bootstrap +
	 * meta-box render).
	 *
	 * @since 3.8.0
	 *
	 * @return WC_Bookings_Reschedule
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'wp_ajax_wc_bookings_admin_reschedule_booking', array( $this, 'ajax_apply_reschedule' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) );
	}

	/**
	 * Whether a booking is in a status that allows rescheduling.
	 *
	 * @param WC_Booking $booking Booking.
	 * @return bool
	 */
	private function is_reschedulable( $booking ) {
		/**
		 * Filters the booking statuses for which the admin reschedule flow is offered.
		 *
		 * Defaults exclude terminal/transient statuses (`cancelled`, `complete`,
		 * `was-in-cart`, `in-cart`) so staff cannot move a booking that no longer
		 * represents an active reservation.
		 *
		 * @since 3.8.0
		 *
		 * @param string[]  $statuses Booking statuses considered reschedulable.
		 * @param WC_Booking $booking  The booking being checked.
		 */
		$allowed = apply_filters(
			'woocommerce_booking_reschedulable_statuses',
			array( 'confirmed', 'paid', 'unpaid', 'pending-confirmation' ),
			$booking
		);

		return in_array( $booking->get_status( 'edit' ), (array) $allowed, true );
	}

	/**
	 * Render the reschedule panel inside the booking details meta box.
	 *
	 * @since 3.8.0
	 *
	 * @param WC_Booking $booking The booking being edited.
	 */
	public function render_panel( $booking ) {
		$product = $booking->get_product();

		// No bookable product → no reschedule (e.g., deleted product, skeleton type).
		if ( ! $product instanceof WC_Product_Booking ) {
			return;
		}

		// Don't offer reschedule for terminal / pre-purchase statuses.
		if ( ! $this->is_reschedulable( $booking ) ) {
			return;
		}

		$this->current_booking = $booking;

		// Scope filters to this render only. The cost-string suppressor runs at PHP_INT_MAX
		// so any third-party customizations register first and are still allowed to compute
		// — we deterministically blank out the final value (resource cost is irrelevant in
		// the reschedule dialog because the order total is locked).
		add_filter( 'woocommerce_bookings_override_form_default_date', array( $this, 'preselect_default_date' ) );
		add_filter( 'woocommerce_bookings_resource_additional_cost_string', array( $this, 'suppress_resource_cost_string' ), PHP_INT_MAX );

		$booking_form = new WC_Booking_Form( $product );

		$current_resource = (int) $booking->get_resource_id( 'edit' );
		$current_duration = $this->get_current_duration_units( $booking, $product );
		$current_persons  = (array) $booking->get_person_counts( 'edit' );

		?>
		<div class="wc-bookings-reschedule-wrapper">
			<button type="button" class="button wc-bookings-reschedule-toggle">
				<?php esc_html_e( 'Reschedule booking', 'woocommerce-bookings' ); ?>
			</button>
			<?php // `id="wc-bookings-booking-form"` mirrors the Add Booking screen so the existing admin.scss rules apply verbatim. ?>
			<dialog id="wc-bookings-booking-form" class="wc-bookings-reschedule-dialog"
				data-booking-id="<?php echo esc_attr( $booking->get_id() ); ?>"
				data-current-resource="<?php echo esc_attr( $current_resource ); ?>"
				data-current-duration="<?php echo esc_attr( $current_duration ); ?>"
				data-current-persons="<?php echo esc_attr( wp_json_encode( $current_persons ) ); ?>"
				data-nonce="<?php echo esc_attr( wp_create_nonce( 'wc_bookings_reschedule_' . $booking->get_id() ) ); ?>">
				<header class="wc-bookings-reschedule-dialog__header">
					<h2 class="wc-bookings-reschedule-dialog__title"><?php esc_html_e( 'Reschedule booking', 'woocommerce-bookings' ); ?></h2>
					<button type="button" class="wc-bookings-reschedule-dialog__close wc-bookings-reschedule-cancel" aria-label="<?php esc_attr_e( 'Close', 'woocommerce-bookings' ); ?>">&times;</button>
				</header>
				<div class="wc-bookings-reschedule-dialog__body">
					<p class="description">
						<?php esc_html_e( 'Pick a new date and time. The price stays the same as the original booking.', 'woocommerce-bookings' ); ?>
					</p>
					<div class="wc-bookings-booking-form">
						<?php // Use a div (not a form) because the panel renders inside WordPress's post edit form and browsers strip nested forms. The `cart` class satisfies the booking form's `.closest('form, .cart')` lookup. ?>
						<div class="cart wc-bookings-reschedule-form" data-nonce="<?php echo esc_attr( wp_create_nonce( 'find-booked-day-blocks' ) ); ?>">
							<input type="hidden" name="add-to-cart" class="wc-booking-product-id" value="<?php echo esc_attr( $product->get_id() ); ?>" />
							<?php $booking_form->output(); ?>
						</div>
						<?php // Mirrors the frontend single-product template; populated by booking-form.js with the calculate_costs response (either the cost HTML or a booking-error message). ?>
						<div class="wc-bookings-booking-cost price" style="display:none" data-raw-price=""></div>
					</div>
				</div>
				<footer class="wc-bookings-reschedule-dialog__footer">
					<div class="wc-bookings-reschedule-message" role="status" aria-live="polite"></div>
					<span class="spinner" style="float:none;"></span>
					<button type="button" class="button wc-bookings-reschedule-cancel">
						<?php esc_html_e( 'Cancel', 'woocommerce-bookings' ); ?>
					</button>
					<button type="button" class="button button-primary wc-bookings-reschedule-submit">
						<?php esc_html_e( 'Apply new date', 'woocommerce-bookings' ); ?>
					</button>
				</footer>
			</dialog>
		</div>
		<?php

		remove_filter( 'woocommerce_bookings_override_form_default_date', array( $this, 'preselect_default_date' ) );
		remove_filter( 'woocommerce_bookings_resource_additional_cost_string', array( $this, 'suppress_resource_cost_string' ), PHP_INT_MAX );

		// Deferred until after `output()` so `wc-bookings-booking-form` is registered
		// when our dep list is processed (WP 6.9.1+ warns otherwise).
		$this->enqueue_script();

		$this->current_booking = null;
	}

	/**
	 * Preselect the current booking's start date in the date picker.
	 *
	 * @internal
	 *
	 * @since 3.8.0
	 *
	 * @param int|null $default_value Existing default (unix timestamp) or null.
	 * @return int|null
	 */
	public function preselect_default_date( $default_value ) {
		if ( $this->current_booking instanceof WC_Booking ) {
			return $this->current_booking->get_start( 'edit' );
		}
		return $default_value;
	}

	/**
	 * Compare two person-count arrays for semantic equality, ignoring zero
	 * entries and key ordering.
	 *
	 * @param array $a First set.
	 * @param array $b Second set.
	 * @return bool
	 */
	private function persons_match( $a, $b ) {
		$normalize = function ( $arr ) {
			$out = array();
			foreach ( (array) $arr as $k => $v ) {
				$v = (int) $v;
				if ( $v > 0 ) {
					$out[ (int) $k ] = $v;
				}
			}
			ksort( $out );
			return $out;
		};

		return $normalize( $a ) === $normalize( $b );
	}

	/**
	 * Current booking's duration in the product's customer duration units (the
	 * value the `wc_bookings_field_duration` input would carry).
	 *
	 * @param WC_Booking         $booking Booking.
	 * @param WC_Product_Booking $product Product.
	 * @return int
	 */
	private function get_current_duration_units( $booking, $product ) {
		if ( 'customer' !== $product->get_duration_type() ) {
			return 1;
		}

		$unit  = $product->get_duration_unit();
		$start = (int) $booking->get_start( 'edit' );
		$end   = (int) $booking->get_end( 'edit' );
		$base  = max( 1, (int) $product->get_duration() );

		if ( 'month' === $unit ) {
			// Months aren't a fixed number of seconds (28–31 days), so diff calendar
			// months instead. Month bookings start on the 1st and store the end as
			// start + (months) - 1 second; +1 recovers the whole span.
			$start_dt = new DateTime( '@' . $start );
			$end_dt   = new DateTime( '@' . ( $end + 1 ) );
			$interval = $start_dt->diff( $end_dt );

			$total_units = ( $interval->y * 12 ) + $interval->m;
			// Round up any partial trailing month (defensive; clean month bookings divide evenly).
			if ( $interval->d > 0 || $interval->h > 0 || $interval->i > 0 || $interval->s > 0 ) {
				++$total_units;
			}

			return max( 1, (int) round( $total_units / $base ) );
		}

		$unit_seconds = array(
			'day'   => DAY_IN_SECONDS,
			'night' => DAY_IN_SECONDS,
			'week'  => WEEK_IN_SECONDS,
		);
		if ( ! isset( $unit_seconds[ $unit ] ) ) {
			return 1;
		}

		// End is stored as start + (units * unit_seconds) - 1 second for all-day bookings; +1 gives the full span.
		$span        = max( 0, $end - $start + 1 );
		$total_units = (int) round( $span / $unit_seconds[ $unit ] );
		return max( 1, (int) round( $total_units / $base ) );
	}

	/**
	 * Suppress the resource additional-cost string in the reschedule dialog.
	 *
	 * Registered at PHP_INT_MAX so any third-party customizations downstream of
	 * the canonical filter still execute; we then deterministically blank the
	 * final value because resource cost is irrelevant when the order total is locked.
	 *
	 * @internal
	 *
	 * @since 3.8.0
	 *
	 * @return string
	 */
	public function suppress_resource_cost_string() {
		return '';
	}

	/**
	 * Enqueue reschedule styles on the booking edit screen.
	 *
	 * The companion JS handle is enqueued from {@see render_panel()} so that its
	 * `wc-bookings-booking-form` dependency is already registered (the booking
	 * form registers its own handle during `output()`). Enqueuing the script here
	 * would trigger WP 6.9.1+'s "dependency not registered" notice.
	 *
	 * @internal
	 *
	 * @since 3.8.0
	 */
	public function enqueue_styles() {
		$screen = get_current_screen();
		if ( ! $screen || 'wc_booking' !== $screen->id ) {
			return;
		}

		// The booking form's visual styling lives in the frontend bundle — reuse the
		// shared helper so the reschedule panel gets the same look as the front-end and
		// "Add Booking" screen. Pass the 'admin' context so the jQuery UI styles use our
		// scoped handle instead of clobbering WC core's `jquery-ui-style` registration.
		WC_Bookings_Init::booking_form_styles( 'admin' );
	}

	/**
	 * Enqueue the reschedule script. Called from {@see render_panel()} after
	 * `WC_Booking_Form::output()` has registered the `wc-bookings-booking-form`
	 * handle we declare as a dependency.
	 */
	private function enqueue_script() {
		$handle = 'wc-bookings-admin-reschedule';

		wp_enqueue_script(
			$handle,
			WC_BOOKINGS_PLUGIN_URL . '/dist/admin-reschedule-booking.js',
			wc_booking_get_script_dependencies( 'admin-reschedule-booking', array( 'jquery', 'wc-bookings-booking-form' ) ),
			WC_BOOKINGS_VERSION,
			true
		);

		wp_localize_script(
			$handle,
			'wc_bookings_reschedule_params',
			array(
				'ajax_url'           => admin_url( 'admin-ajax.php' ),
				'i18n_pick_slot'     => __( 'Please pick a date and time first.', 'woocommerce-bookings' ),
				'i18n_success'       => __( 'Booking rescheduled. Reloading…', 'woocommerce-bookings' ),
				'i18n_generic_error' => __( 'Could not reschedule the booking. Please try again.', 'woocommerce-bookings' ),
			)
		);
	}

	/**
	 * AJAX: apply a reschedule to an existing booking.
	 *
	 * @internal
	 *
	 * @since 3.8.0
	 */
	public function ajax_apply_reschedule() {
		$booking_id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : 0;

		if ( ! $booking_id || ! check_ajax_referer( 'wc_bookings_reschedule_' . $booking_id, 'security', false ) ) {
			wp_send_json_error( array( 'message' => __( 'Invalid request.', 'woocommerce-bookings' ) ), 400 );
		}

		if ( ! current_user_can( 'edit_post', $booking_id ) ) {
			wp_send_json_error( array( 'message' => __( 'You do not have permission to edit this booking.', 'woocommerce-bookings' ) ), 403 );
		}

		try {
			$booking = new WC_Booking( $booking_id );
		} catch ( Exception $e ) {
			wp_send_json_error( array( 'message' => __( 'Booking not found.', 'woocommerce-bookings' ) ), 404 );
		}

		$product = $booking->get_product();
		if ( ! $product instanceof WC_Product_Booking ) {
			wp_send_json_error( array( 'message' => __( 'This booking is missing its product.', 'woocommerce-bookings' ) ), 400 );
		}

		if ( ! $this->is_reschedulable( $booking ) ) {
			wp_send_json_error( array( 'message' => __( 'This booking cannot be rescheduled in its current status.', 'woocommerce-bookings' ) ), 409 );
		}

		// Posted data uses the same field names as the booking form, so we can reuse the parser.
		// `wc_bookings_get_posted_data()` parses raw `$_POST` through `new DateTime()`, which
		// can throw on a malformed date string — return a clean 400 instead of an unhandled exception.
		try {
			$posted_data = wc_bookings_get_posted_data( wp_unslash( $_POST ), $product ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		} catch ( Exception $e ) {
			wp_send_json_error( array( 'message' => __( 'Could not parse the submitted date.', 'woocommerce-bookings' ) ), 400 );
		}

		if ( empty( $posted_data['_start_date'] ) || empty( $posted_data['_end_date'] ) ) {
			wp_send_json_error( array( 'message' => __( 'Please choose a date and time.', 'woocommerce-bookings' ) ), 400 );
		}

		$new_start    = (int) $posted_data['_start_date'];
		$new_end      = (int) $posted_data['_end_date'];
		$new_resource = isset( $posted_data['_resource_id'] ) ? (int) $posted_data['_resource_id'] : 0;
		$new_persons  = isset( $posted_data['_persons'] ) ? (array) $posted_data['_persons'] : array();
		$old_start    = (int) $booking->get_start( 'edit' );
		$old_end      = (int) $booking->get_end( 'edit' );
		$old_resource = (int) $booking->get_resource_id( 'edit' );
		$old_persons  = (array) $booking->get_person_counts( 'edit' );

		// Short-circuit: nothing changed — skip the write entirely.
		if (
			$new_start === $old_start
			&& $new_end === $old_end
			&& $new_resource === $old_resource
			&& $this->persons_match( $new_persons, $old_persons )
		) {
			wp_send_json_success( array( 'message' => __( 'No change — booking already on that slot.', 'woocommerce-bookings' ) ) );
		}

		// Mirror WC_Bookings_Create::output: trust the posted data. The booking form's
		// availability query + calculate_costs round-trip (gated by the Apply button in
		// admin-reschedule-booking.js) has already validated the selection client-side.
		$booking->set_props(
			array(
				'start'         => $new_start,
				'end'           => $new_end,
				'resource_id'   => $new_resource,
				'person_counts' => $new_persons,
			)
		);

		try {
			$saved_id = $booking->save();
		} catch ( Exception $e ) {
			wp_send_json_error( array( 'message' => __( 'Could not save the rescheduled booking.', 'woocommerce-bookings' ) ), 500 );
		}

		if ( ! $saved_id ) {
			wp_send_json_error( array( 'message' => __( 'Could not save the rescheduled booking.', 'woocommerce-bookings' ) ), 500 );
		}

		// Audit trail on the linked order, if any.
		$order = $booking->get_order();
		if ( $order ) {
			$note = sprintf(
				/* translators: 1: booking id, 2: old start, 3: new start */
				__( 'Booking #%1$d rescheduled from %2$s to %3$s.', 'woocommerce-bookings' ),
				$booking->get_id(),
				date_i18n( wc_bookings_date_format() . ' ' . wc_bookings_time_format(), $old_start ),
				date_i18n( wc_bookings_date_format() . ' ' . wc_bookings_time_format(), $new_start )
			);

			if ( $new_resource !== $old_resource ) {
				$old_resource_obj  = $old_resource ? $product->get_resource( $old_resource ) : false;
				$new_resource_obj  = $new_resource ? $product->get_resource( $new_resource ) : false;
				$old_resource_name = $old_resource_obj ? $old_resource_obj->get_name() : __( 'none', 'woocommerce-bookings' );
				$new_resource_name = $new_resource_obj ? $new_resource_obj->get_name() : __( 'none', 'woocommerce-bookings' );

				$note .= ' ' . sprintf(
					/* translators: 1: previous resource name, 2: new resource name */
					__( 'Resource changed from %1$s to %2$s.', 'woocommerce-bookings' ),
					$old_resource_name,
					$new_resource_name
				);
			}

			$order->add_order_note( $note );
		}

		/**
		 * Fires after a booking has been rescheduled via the admin reschedule flow.
		 *
		 * @since 3.8.0
		 *
		 * @param int   $booking_id   Booking ID.
		 * @param int   $old_start    Previous start timestamp.
		 * @param int   $new_start    New start timestamp.
		 * @param int   $old_resource Previous resource ID (0 if none).
		 * @param int   $new_resource New resource ID (0 if none).
		 * @param array $old_persons  Previous person counts keyed by person type ID.
		 * @param array $new_persons  New person counts keyed by person type ID.
		 */
		do_action( 'woocommerce_booking_rescheduled', $booking->get_id(), $old_start, $new_start, $old_resource, $new_resource, $old_persons, $new_persons );

		/**
		 * Generic "admin saved this booking" action. Fired for backwards compatibility
		 * with third parties that already hook in here. New integrations should prefer
		 * `woocommerce_booking_rescheduled` above for richer context.
		 *
		 * @since 3.8.0
		 *
		 * @param int $booking_id Booking ID.
		 */
		do_action( 'woocommerce_booking_process_meta', $booking->get_id() );

		wp_send_json_success(
			array(
				'message' => __( 'Booking rescheduled.', 'woocommerce-bookings' ),
			)
		);
	}
}
