<?php
/**
 * Shared booking records data service.
 *
 * @package TutorHelper_Bookings
 */

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

/**
 * Creates and retrieves TutorHelper booking records.
 *
 * Both tutor-created and student-created booking interfaces must use
 * this service so that pricing, duration, timezone and payment rules
 * remain consistent.
 */
final class TutorHelper_Booking_Records {

	/**
	 * Student self-booking source.
	 *
	 * @var string
	 */
	const SOURCE_STUDENT = 'student_booking';

	/**
	 * Tutor-created manual booking source.
	 *
	 * @var string
	 */
	const SOURCE_TUTOR = 'tutor_manual';

	/**
	 * Confirmed booking status.
	 *
	 * @var string
	 */
	const STATUS_CONFIRMED = 'confirmed';

	/**
	 * Cancelled booking status.
	 *
	 * @var string
	 */
	const STATUS_CANCELLED = 'cancelled';

	/**
	 * Unconfirmed booking status.
	 *
	 * Tutor-created bookings may use this status when payment or
	 * attendance confirmation is still outstanding.
	 *
	 * @var string
	 */
	const STATUS_UNCONFIRMED = 'unconfirmed';

	/**
	 * Free payment method.
	 *
	 * @var string
	 */
	const PAYMENT_METHOD_FREE = 'free';

	/**
	 * Stripe payment method.
	 *
	 * @var string
	 */
	const PAYMENT_METHOD_STRIPE = 'stripe';

	/**
	 * Manual payment method.
	 *
	 * @var string
	 */
	const PAYMENT_METHOD_MANUAL = 'manual';

	/**
	 * No payment is required.
	 *
	 * @var string
	 */
	const PAYMENT_STATUS_NOT_REQUIRED = 'not_required';

	/**
	 * Payment is awaiting completion.
	 *
	 * @var string
	 */
	const PAYMENT_STATUS_PENDING = 'pending';



    /**
	 * Return the bookings table name.
	 *
	 * @return string
	 */
	public static function get_table_name() {
		return TutorHelper_Bookings_Database::get_table_name(
			'bookings'
		);
	}


    /**
	 * Retrieve one booking by ID.
	 *
	 * @param int $booking_id Booking ID.
	 *
	 * @return object|null
	 */
	public static function get_by_id(
		$booking_id
	) {
		global $wpdb;

		$booking_id = absint(
			$booking_id
		);

		if ( ! $booking_id ) {
			return null;
		}

		$table_name = self::get_table_name();

		if ( empty( $table_name ) ) {
			return null;
		}

		return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
			$wpdb->prepare(
				'SELECT *
				FROM %i
				WHERE id = %d',
				$table_name,
				$booking_id
			)
		);
	}

	/**
	 * Update a booking status.
	 *
	 * Optionally require the booking to currently have an expected
	 * status before applying the change.
	 *
	 * @param int    $booking_id      Booking ID.
	 * @param string $new_status      New booking status.
	 * @param string $expected_status Optional current status requirement.
	 *
	 * @return true|WP_Error
	 */
	public static function update_status(
		$booking_id,
		$new_status,
		$expected_status = ''
	) {
		global $wpdb;

		$booking_id =
			absint(
				$booking_id
			);

		if ( ! $booking_id ) {
			return new WP_Error(
				'tutorhelper_booking_status_invalid_id',
				__(
					'The booking could not be found.',
					'tutorhelper-bookings'
				)
			);
		}

		$new_status =
			sanitize_key(
				$new_status
			);

		$expected_status =
			sanitize_key(
				$expected_status
			);

		$allowed_statuses = array(
			self::STATUS_CONFIRMED,
			self::STATUS_UNCONFIRMED,
			self::STATUS_CANCELLED,
		);

		if (
			! in_array(
				$new_status,
				$allowed_statuses,
				true
			)
		) {
			return new WP_Error(
				'tutorhelper_booking_status_invalid',
				__(
					'The booking status is invalid.',
					'tutorhelper-bookings'
				)
			);
		}

		if (
			''
			!== $expected_status
			&& ! in_array(
				$expected_status,
				$allowed_statuses,
				true
			)
		) {
			return new WP_Error(
				'tutorhelper_booking_expected_status_invalid',
				__(
					'The current booking status is invalid.',
					'tutorhelper-bookings'
				)
			);
		}

		$table_name =
			self::get_table_name();

		if ( empty( $table_name ) ) {
			return new WP_Error(
				'tutorhelper_booking_table_missing',
				__(
					'The booking could not be updated.',
					'tutorhelper-bookings'
				)
			);
		}

		$updated_at =
			current_time(
				'mysql',
				true
			);

		if ( '' !== $expected_status ) {
			$result =
				$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- TutorHelper stores bookings in a custom table; this is a write operation, so query-result caching does not apply.
					$wpdb->prepare(
						'UPDATE %i
						SET status = %s,
							updated_at = %s
						WHERE id = %d
						AND status = %s',
						$table_name,
						$new_status,
						$updated_at,
						$booking_id,
						$expected_status
					)
				);
		} else {
			$result =
				$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- TutorHelper stores bookings in a custom table; this is a write operation, so query-result caching does not apply.
					$wpdb->prepare(
						'UPDATE %i
						SET status = %s,
							updated_at = %s
						WHERE id = %d',
						$table_name,
						$new_status,
						$updated_at,
						$booking_id
					)
				);
		}

		if ( false === $result ) {
			return new WP_Error(
				'tutorhelper_booking_status_update_failed',
				__(
					'The booking status could not be updated.',
					'tutorhelper-bookings'
				)
			);
		}

		if ( 0 === $result ) {
			$booking =
				self::get_by_id(
					$booking_id
				);

			if ( ! $booking ) {
				return new WP_Error(
					'tutorhelper_booking_status_not_found',
					__(
						'The booking could not be found.',
						'tutorhelper-bookings'
					)
				);
			}

			return new WP_Error(
				'tutorhelper_booking_status_changed',
				__(
					'The booking status has changed. Refresh the bookings table and try again.',
					'tutorhelper-bookings'
				)
			);
		}

		return true;
	}


    /**
	 * Retrieve booking records.
	 *
	 * Supported filters:
	 * - student_user_id
	 * - tutor_user_id
	 * - booking_type_id
	 * - status
	 * - date_from
	 * - date_to
	 * - limit
	 *
	 * Dates must be UTC database values.
	 *
	 * @param array $args Query arguments.
	 *
	 * @return array
	 */
	public static function get_all(
		$args = array()
	) {
		global $wpdb;

		$args = wp_parse_args(
			is_array( $args ) ? $args : array(),
			array(
				'student_user_id' => 0,
				'tutor_user_id'   => 0,
				'booking_type_id' => 0,
				'status'          => '',
				'date_from'       => '',
				'date_to'         => '',
				'limit'           => 200,
			)
		);

		$table_name = self::get_table_name();

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

		$student_user_id = absint(
			$args['student_user_id']
		);

		$tutor_user_id = absint(
			$args['tutor_user_id']
		);

		$booking_type_id = absint(
			$args['booking_type_id']
		);

		$status = ! empty( $args['status'] )
			? sanitize_key(
				$args['status']
			)
			: '';

		$date_from = ! empty( $args['date_from'] )
			? sanitize_text_field(
				$args['date_from']
			)
			: '';

		$date_to = ! empty( $args['date_to'] )
			? sanitize_text_field(
				$args['date_to']
			)
			: '';

		$limit = max(
			1,
			min(
				1000,
				absint( $args['limit'] )
			)
		);

		$results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
			$wpdb->prepare(
				'SELECT *
				FROM %i
				WHERE ( %d = 0 OR student_user_id = %d )
				AND ( %d = 0 OR tutor_user_id = %d )
				AND ( %d = 0 OR booking_type_id = %d )
				AND ( %s = \'\' OR status = %s )
				AND ( %s = \'\' OR booking_start >= %s )
				AND ( %s = \'\' OR booking_start <= %s )
				ORDER BY booking_start ASC
				LIMIT %d',
				$table_name,
				$student_user_id,
				$student_user_id,
				$tutor_user_id,
				$tutor_user_id,
				$booking_type_id,
				$booking_type_id,
				$status,
				$status,
				$date_from,
				$date_from,
				$date_to,
				$date_to,
				$limit
			)
		);

		return is_array( $results )
			? $results
			: array();
	}


	/**
	 * Retrieve a paginated set of booking records.
	 *
	 * Supported filters:
	 * - student_user_id
	 * - student_search
	 * - tutor_user_id
	 * - booking_type_id
	 * - status
	 * - status_exclude
	 * - date_from
	 * - date_to
	 * - time_scope
	 * - sort_order
	 * - page
	 * - per_page
	 *
	 * Dates must be UTC database values.
	 *
	 * @param array $args Query arguments.
	 *
	 * @return array
	 */
	public static function query(
		$args = array()
	) {
		global $wpdb;

		$args = wp_parse_args(
			is_array( $args )
				? $args
				: array(),
			array(
				'student_user_id' => 0,
				'student_search'  => '',
				'tutor_user_id'   => 0,
				'booking_type_id' => 0,
				'status'          => '',
				'status_exclude'  => '',
				'date_from'       => '',
				'date_to'         => '',
				'time_scope'      => 'all',
				'sort_order'      => 'date_asc',
				'page'            => 1,
				'per_page'        => 20,
			)
		);

		$table_name =
			self::get_table_name();

		if ( empty( $table_name ) ) {
			return array(
				'bookings' =>
					array(),

				'total' =>
					0,

				'total_pages' =>
					0,

				'page' =>
					1,

				'per_page' =>
					20,
			);
		}

		$student_user_id =
			absint(
				$args['student_user_id']
			);

		$student_search =
			! empty(
				$args['student_search']
			)
				? sanitize_text_field(
					$args['student_search']
				)
				: '';

		$student_search_like =
			! empty( $student_search )
				? '%'
					. $wpdb->esc_like(
						$student_search
					)
					. '%'
				: '';

		$tutor_user_id =
			absint(
				$args['tutor_user_id']
			);

		$booking_type_id =
			absint(
				$args['booking_type_id']
			);

		$status =
			! empty(
				$args['status']
			)
				? sanitize_key(
					$args['status']
				)
				: '';
		
		$status_exclude =
			! empty(
				$args['status_exclude']
			)
				? sanitize_key(
					$args['status_exclude']
				)
				: '';

		$date_from =
			! empty(
				$args['date_from']
			)
				? sanitize_text_field(
					$args['date_from']
				)
				: '';

		$date_to =
			! empty(
				$args['date_to']
			)
				? sanitize_text_field(
					$args['date_to']
				)
				: '';

		$time_scope =
			sanitize_key(
				$args['time_scope']
			);

		if (
			! in_array(
				$time_scope,
				array(
					'all',
					'upcoming',
					'past',
				),
				true
			)
		) {
			$time_scope = 'all';
		}

		$sort_order =
			sanitize_key(
				$args['sort_order']
			);

		if (
			! in_array(
				$sort_order,
				array(
					'date_asc',
					'date_desc',
					'upcoming_first',
				),
				true
			)
		) {
			$sort_order =
				'date_asc';
		}

		$page =
			max(
				1,
				absint(
					$args['page']
				)
			);

		$per_page =
			max(
				1,
				min(
					100,
					absint(
						$args['per_page']
					)
				)
			);

		$now_utc =
			current_time(
				'mysql',
				true
			);

		/*
		* time_scope is separate from date_from/date_to.
		*
		* This lets callers combine, for example:
		*
		* upcoming + date_to
		*/
		if (
			'upcoming'
			=== $time_scope
			&& (
				empty( $date_from )
				|| $date_from < $now_utc
			)
		) {
			$date_from = $now_utc;
		}

		if (
			'past'
			=== $time_scope
			&& (
				empty( $date_to )
				|| $date_to > $now_utc
			)
		) {
			$date_to = $now_utc;
		}

		$total =
			absint(
				$wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
					$wpdb->prepare(
						'SELECT COUNT(*)
						FROM %i
						WHERE ( %d = 0 OR student_user_id = %d )
						AND ( %s = \'\' OR student_name LIKE %s )
						AND ( %d = 0 OR tutor_user_id = %d )
						AND ( %d = 0 OR booking_type_id = %d )
						AND ( %s = \'\' OR status = %s )
						AND ( %s = \'\' OR status != %s )
						AND ( %s = \'\' OR booking_start >= %s )
						AND ( %s = \'\' OR booking_start <= %s )',
						$table_name,
						$student_user_id,
						$student_user_id,
						$student_search,
						$student_search_like,
						$tutor_user_id,
						$tutor_user_id,
						$booking_type_id,
						$booking_type_id,
						$status,
						$status,
						$status_exclude,
						$status_exclude,
						$date_from,
						$date_from,
						$date_to,
						$date_to
					)
				)
			);

		$total_pages =
			0 < $total
				? (int) ceil(
					$total
					/ $per_page
				)
				: 0;

		if (
			0 < $total_pages
			&& $page > $total_pages
		) {
			$page =
				$total_pages;
		}

		$offset =
			( $page - 1 )
			* $per_page;

		if (
			'upcoming_first'
			=== $sort_order
		) {
			$results =
				$wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
					$wpdb->prepare(
						'SELECT *
						FROM %i
						WHERE ( %d = 0 OR student_user_id = %d )
						AND ( %s = \'\' OR student_name LIKE %s )
						AND ( %d = 0 OR tutor_user_id = %d )
						AND ( %d = 0 OR booking_type_id = %d )
						AND ( %s = \'\' OR status = %s )
						AND ( %s = \'\' OR status != %s )
						AND ( %s = \'\' OR booking_start >= %s )
						AND ( %s = \'\' OR booking_start <= %s )
						ORDER BY
							CASE
								WHEN booking_start >= %s THEN 0
								ELSE 1
							END ASC,
							CASE
								WHEN booking_start >= %s THEN booking_start
							END ASC,
							CASE
								WHEN booking_start < %s THEN booking_start
							END DESC
						LIMIT %d
						OFFSET %d',
						$table_name,
						$student_user_id,
						$student_user_id,
						$student_search,
						$student_search_like,
						$tutor_user_id,
						$tutor_user_id,
						$booking_type_id,
						$booking_type_id,
						$status,
						$status,
						$status_exclude,
						$status_exclude,
						$date_from,
						$date_from,
						$date_to,
						$date_to,
						$now_utc,
						$now_utc,
						$now_utc,
						$per_page,
						$offset
					)
				);
		} elseif (
			'date_desc'
			=== $sort_order
		) {
			$results =
				$wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
					$wpdb->prepare(
						'SELECT *
						FROM %i
						WHERE ( %d = 0 OR student_user_id = %d )
						AND ( %s = \'\' OR student_name LIKE %s )
						AND ( %d = 0 OR tutor_user_id = %d )
						AND ( %d = 0 OR booking_type_id = %d )
						AND ( %s = \'\' OR status = %s )
						AND ( %s = \'\' OR status != %s )
						AND ( %s = \'\' OR booking_start >= %s )
						AND ( %s = \'\' OR booking_start <= %s )
						ORDER BY booking_start DESC
						LIMIT %d
						OFFSET %d',
						$table_name,
						$student_user_id,
						$student_user_id,
						$student_search,
						$student_search_like,
						$tutor_user_id,
						$tutor_user_id,
						$booking_type_id,
						$booking_type_id,
						$status,
						$status,
						$status_exclude,
						$status_exclude,
						$date_from,
						$date_from,
						$date_to,
						$date_to,
						$per_page,
						$offset
					)
				);
		} else {
			$results =
				$wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
					$wpdb->prepare(
						'SELECT *
						FROM %i
						WHERE ( %d = 0 OR student_user_id = %d )
						AND ( %s = \'\' OR student_name LIKE %s )
						AND ( %d = 0 OR tutor_user_id = %d )
						AND ( %d = 0 OR booking_type_id = %d )
						AND ( %s = \'\' OR status = %s )
						AND ( %s = \'\' OR status != %s )
						AND ( %s = \'\' OR booking_start >= %s )
						AND ( %s = \'\' OR booking_start <= %s )
						ORDER BY booking_start ASC
						LIMIT %d
						OFFSET %d',
						$table_name,
						$student_user_id,
						$student_user_id,
						$student_search,
						$student_search_like,
						$tutor_user_id,
						$tutor_user_id,
						$booking_type_id,
						$booking_type_id,
						$status,
						$status,
						$status_exclude,
						$status_exclude,
						$date_from,
						$date_from,
						$date_to,
						$date_to,
						$per_page,
						$offset
					)
				);
		}

		return array(
			'bookings' =>
				is_array( $results )
					? $results
					: array(),

			'total' =>
				$total,

			'total_pages' =>
				$total_pages,

			'page' =>
				$page,

			'per_page' =>
				$per_page,
		);
	}


    /**
	 * Return a valid timezone identifier.
	 *
	 * @param string $timezone Raw timezone identifier.
	 *
	 * @return string
	 */
	private static function sanitize_timezone(
		$timezone
	) {
		$timezone = sanitize_text_field(
			(string) $timezone
		);

		if (
			! empty( $timezone )
			&& in_array(
				$timezone,
				timezone_identifiers_list(),
				true
			)
		) {
			return $timezone;
		}

		$site_timezone = wp_timezone_string();

		if ( ! empty( $site_timezone ) ) {
			return $site_timezone;
		}

		return 'UTC';
	}


    /**
	 * Convert a local booking date and time into UTC.
	 *
	 * @param string $date_time Local date and time.
	 * @param string $timezone  IANA timezone identifier.
	 *
	 * @return DateTimeImmutable|WP_Error UTC date object.
	 */
	private static function parse_to_utc(
		$date_time,
		$timezone
	) {
		$date_time = sanitize_text_field(
			(string) $date_time
		);

		if ( empty( $date_time ) ) {
			return new WP_Error(
				'tutorhelper_booking_date_missing',
				__(
					'A booking date and time is missing.',
					'tutorhelper-bookings'
				)
			);
		}

		try {
			$source_timezone =
				new DateTimeZone(
					$timezone
				);

			$local_date =
				new DateTimeImmutable(
					$date_time,
					$source_timezone
				);

			return $local_date->setTimezone(
				new DateTimeZone( 'UTC' )
			);
		} catch ( Exception $exception ) {
			return new WP_Error(
				'tutorhelper_booking_date_invalid',
				__(
					'The booking date or time is invalid.',
					'tutorhelper-bookings'
				)
			);
		}
	}


    /**
	 * Derive the duration between two UTC dates.
	 *
	 * @param DateTimeImmutable $start Start date.
	 * @param DateTimeImmutable $end   End date.
	 *
	 * @return int|WP_Error Duration in minutes.
	 */
	private static function calculate_duration_minutes(
		$start,
		$end
	) {
		if (
			! $start instanceof DateTimeImmutable
			|| ! $end instanceof DateTimeImmutable
		) {
			return new WP_Error(
				'tutorhelper_booking_dates_invalid',
				__(
					'The booking dates are invalid.',
					'tutorhelper-bookings'
				)
			);
		}

		$duration_seconds =
			$end->getTimestamp()
			- $start->getTimestamp();

		if ( 0 >= $duration_seconds ) {
			return new WP_Error(
				'tutorhelper_booking_end_before_start',
				__(
					'The booking end time must be after its start time.',
					'tutorhelper-bookings'
				)
			);
		}

		if (
			0 !== $duration_seconds
			% MINUTE_IN_SECONDS
		) {
			return new WP_Error(
				'tutorhelper_booking_partial_minute',
				__(
					'The booking duration must use complete minutes.',
					'tutorhelper-bookings'
				)
			);
		}

		return (int) (
			$duration_seconds
			/ MINUTE_IN_SECONDS
		);
	}


	/**
	 * Resolve the authoritative price for a student booking.
	 *
	 * Core calculates the normal Booking Type price first. Extensions may
	 * then adjust the price or pricing basis using the complete booking
	 * context.
	 *
	 * This method may also be used by trusted Core interfaces to preview
	 * the same price that will be recalculated when a booking is saved.
	 */
	public static function resolve_pricing(
		$student_user_id,
		$booking_type_id,
		$duration_minutes
	) {

		$student_user_id =
			absint( $student_user_id );

		$booking_type_id =
			absint( $booking_type_id );

		$duration_minutes =
			absint( $duration_minutes );

		$price_minor_units =
			TutorHelper_Booking_Types::calculate_price(
				$booking_type_id,
				$duration_minutes
			);

		if ( is_wp_error( $price_minor_units ) ) {
			return $price_minor_units;
		}

		$pricing = array(
			'price_minor_units' =>
				absint( $price_minor_units ),

			'pricing_basis' =>
				'booking_type',
		);

		/**
		 * Filters the resolved price for a student booking.
		 *
		 * Extensions may change the calculated price or identify an
		 * alternative pricing basis. Core's standard Booking Type price
		 * remains the default when no extension modifies the result.
		 *
		 * @param array $pricing {
		 *     Resolved pricing information.
		 *
		 *     @type int    $price_minor_units Price in minor currency units.
		 *     @type string $pricing_basis     Pricing source identifier.
		 * }
		 * @param int $student_user_id Student WordPress user ID.
		 * @param int $booking_type_id Booking Type ID.
		 * @param int $duration_minutes Actual booking duration.
		 */
		$pricing = apply_filters(
			'tutorhelper_booking_resolved_pricing',
			$pricing,
			$student_user_id,
			$booking_type_id,
			$duration_minutes
		);

		/*
		* Never allow a malformed extension response to corrupt
		* the booking record.
		*/
		if ( ! is_array( $pricing ) ) {
			return new WP_Error(
				'tutorhelper_booking_pricing_invalid',
				__(
					'The booking price could not be resolved.',
					'tutorhelper-bookings'
				)
			);
		}

		if (
			! isset( $pricing['price_minor_units'] )
			|| ! is_numeric(
				$pricing['price_minor_units']
			)
			|| 0 > (float) $pricing['price_minor_units']
		) {
			return new WP_Error(
				'tutorhelper_booking_price_invalid',
				__(
					'The booking price is invalid.',
					'tutorhelper-bookings'
				)
			);
		}

		$pricing_basis = isset(
			$pricing['pricing_basis']
		)
			? sanitize_key(
				$pricing['pricing_basis']
			)
			: '';

		if ( '' === $pricing_basis ) {
			$pricing_basis =
				'booking_type';
		}

		return array(
			'price_minor_units' =>
				absint(
					$pricing['price_minor_units']
				),

			'pricing_basis' =>
				$pricing_basis,
		);
	}


    /**
	 * Sanitise the booking source.
	 *
	 * @param string $source Raw source.
	 *
	 * @return string
	 */
	private static function sanitize_source(
		$source
	) {
		$source = sanitize_key(
			$source
		);

		$allowed_sources = array(
			self::SOURCE_STUDENT,
			self::SOURCE_TUTOR,
		);

		if (
			! in_array(
				$source,
				$allowed_sources,
				true
			)
		) {
			return self::SOURCE_STUDENT;
		}

		return $source;
	}


	/**
	 * Choose a valid status for a new booking.
	 *
	 * Student-created Core bookings are lesson requests and therefore
	 * always begin as unconfirmed. Tutor-created bookings may explicitly
	 * choose confirmed or unconfirmed.
	 *
	 * Cancelled is deliberately excluded because cancellation is a later
	 * booking-management action.
	 *
	 * @param string $status Raw booking status.
	 * @param string $source Booking source.
	 *
	 * @return string
	 */
	private static function sanitize_status(
		$status,
		$source
	) {
		if (
			self::SOURCE_STUDENT
				=== $source
		) {
			return self::STATUS_UNCONFIRMED;
		}

		$status = sanitize_key(
			(string) $status
		);

		$allowed_statuses = array(
			self::STATUS_CONFIRMED,
			self::STATUS_UNCONFIRMED,
		);

		if (
			! in_array(
				$status,
				$allowed_statuses,
				true
			)
		) {
			return self::STATUS_CONFIRMED;
		}

		return $status;
	}


	/**
	 * Return locations supported by stored booking records.
	 *
	 * @return array
	 */
	private static function get_supported_locations() {
		return array(
			'online',
			'phone',
			'in_person',
			'custom',
			'other',
		);
	}





    /**
	 * Return valid Booking Type location options.
	 *
	 * @param object $booking_type Booking Type record.
	 *
	 * @return array
	 */
	private static function get_allowed_locations(
		$booking_type
	) {
		$locations = ! empty(
			$booking_type->location_types
		)
			? explode(
				',',
				$booking_type->location_types
			)
			: array(
				$booking_type->location_type,
			);

		$allowed =
			self::get_supported_locations();

		$clean_locations = array();

		foreach ( $locations as $location ) {
			$location = sanitize_key(
				$location
			);

			if (
				in_array(
					$location,
					$allowed,
					true
				)
				&& ! in_array(
					$location,
					$clean_locations,
					true
				)
			) {
				$clean_locations[] = $location;
			}
		}

		if ( empty( $clean_locations ) ) {
			$clean_locations[] = 'online';
		}

		return $clean_locations;
	}


    /**
	 * Choose a valid booking location.
	 *
	 * Tutor-created bookings may use any supported location. Student
	 * bookings remain limited to locations configured on the selected
	 * Booking Type.
	 *
	 * @param string $location_type Raw location.
	 * @param object $booking_type  Booking Type record.
	 * @param string $source        Booking source.
	 *
	 * @return string
	 */
	private static function sanitize_location_type(
		$location_type,
		$booking_type,
		$source
	) {
		$location_type = sanitize_key(
			(string) $location_type
		);

		/*
		* Tutors may override public Booking Type location restrictions,
		* but they must still use a location understood by TutorHelper.
		*/
		if (
			self::SOURCE_TUTOR
				=== $source
		) {
			if (
				in_array(
					$location_type,
					self::get_supported_locations(),
					true
				)
			) {
				return $location_type;
			}

			/*
			* An invalid tutor location falls back to the Booking Type's
			* primary location rather than storing an unknown value.
			*/
			$primary_location = sanitize_key(
				$booking_type->location_type
			);

			if (
				in_array(
					$primary_location,
					self::get_supported_locations(),
					true
				)
			) {
				return $primary_location;
			}

			return 'online';
		}

		/*
		* Student-created bookings remain restricted to the Booking Type.
		*/
		$allowed_locations =
			self::get_allowed_locations(
				$booking_type
			);

		if (
			in_array(
				$location_type,
				$allowed_locations,
				true
			)
		) {
			return $location_type;
		}

		$primary_location = sanitize_key(
			$booking_type->location_type
		);

		if (
			in_array(
				$primary_location,
				$allowed_locations,
				true
			)
		) {
			return $primary_location;
		}

		return $allowed_locations[0];
	}


    /**
	 * Build payment defaults for a new booking.
	 *
	 * @param int    $price_minor_units Calculated price.
	 * @param string $source            Booking source.
	 * @param array  $data              Submitted booking data.
	 *
	 * @return array
	 */
	private static function get_payment_data(
		$price_minor_units,
		$source,
		$data
	) {
		$price_minor_units = absint(
			$price_minor_units
		);

		if ( 0 === $price_minor_units ) {
			return array(
				'payment_method'    =>
					self::PAYMENT_METHOD_FREE,
				'payment_status'    =>
					self::PAYMENT_STATUS_NOT_REQUIRED,
				'payment_provider'  => '',
				'payment_reference' => '',
			);
		}

		$skip_payment =
			isset( $data['skip_payment'] )
			&& true === rest_sanitize_boolean(
				$data['skip_payment']
			);

		if ( $skip_payment ) {
			return array(
				'payment_method' =>
					self::PAYMENT_METHOD_FREE,

				'payment_status' =>
					self::PAYMENT_STATUS_NOT_REQUIRED,

				'payment_provider' =>
					'',

				'payment_reference' =>
					'',
			);
		}


		$allowed_methods = array(
			self::PAYMENT_METHOD_STRIPE,
			self::PAYMENT_METHOD_MANUAL,
		);

		$default_method =
			self::SOURCE_TUTOR === $source
				? self::PAYMENT_METHOD_MANUAL
				: self::PAYMENT_METHOD_STRIPE;

		$payment_method = isset(
			$data['payment_method']
		)
			? sanitize_key(
				$data['payment_method']
			)
			: $default_method;

		if (
			! in_array(
				$payment_method,
				$allowed_methods,
				true
			)
		) {
			$payment_method = $default_method;
		}

		return array(
			'payment_method'    =>
				$payment_method,
			'payment_status'    =>
				self::PAYMENT_STATUS_PENDING,
			'payment_provider'  =>
				self::PAYMENT_METHOD_STRIPE
				=== $payment_method
					? 'stripe'
					: 'manual',
			'payment_reference' => '',
		);
	}


    /**
	 * Check whether a tutor already has an overlapping booking.
	 *
	 * Cancelled bookings do not block time.
	 *
	 * @param int    $tutor_user_id Tutor user ID.
	 * @param string $booking_start UTC start value.
	 * @param string $booking_end   UTC end value.
	 * @param int    $exclude_id    Booking ID to ignore.
	 *
	 * @return bool
	 */
	public static function has_conflict(
		$tutor_user_id,
		$booking_start,
		$booking_end,
		$exclude_id = 0
	) {
		global $wpdb;

		$tutor_user_id = absint(
			$tutor_user_id
		);

		if ( ! $tutor_user_id ) {
			return false;
		}

		$table_name = self::get_table_name();

		if ( $exclude_id ) {
			$count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
				$wpdb->prepare(
					'SELECT COUNT(*)
					FROM %i
					WHERE tutor_user_id = %d
					AND status <> %s
					AND booking_start < %s
					AND booking_end > %s
					AND id <> %d',
					$table_name,
					$tutor_user_id,
					self::STATUS_CANCELLED,
					$booking_end,
					$booking_start,
					absint( $exclude_id )
				)
			);
		} else {
			$count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Booking state must be current; stale cached results could expose occupied slots, outdated statuses, or incorrect booking lists.
				$wpdb->prepare(
					'SELECT COUNT(*)
					FROM %i
					WHERE tutor_user_id = %d
					AND status <> %s
					AND booking_start < %s
					AND booking_end > %s',
					$table_name,
					$tutor_user_id,
					self::STATUS_CANCELLED,
					$booking_end,
					$booking_start
				)
			);
		}

		return 0 < absint( $count );
	}



    /**
	 * Create a single booking record.
	 *
	 * The recurrence layer must call this method once for each accepted
	 * occurrence rather than inserting records directly.
	 *
	 * @param array $data Raw booking data.
	 *
	 * @return int|WP_Error Booking ID on success.
	 */
	public static function create(
		$data
	) {
		global $wpdb;

		$data = is_array( $data )
			? $data
			: array();

		$booking_type_id = isset(
			$data['booking_type_id']
		)
			? absint(
				$data['booking_type_id']
			)
			: 0;

		$student_user_id = isset(
			$data['student_user_id']
		)
			? absint(
				$data['student_user_id']
			)
			: 0;

		$tutor_user_id = isset(
			$data['tutor_user_id']
		)
			? absint(
				$data['tutor_user_id']
			)
			: 0;

		$source = self::sanitize_source(
			isset( $data['source'] )
				? $data['source']
				: self::SOURCE_STUDENT
		);

		$booking_type =
			TutorHelper_Booking_Types::get_by_id(
				$booking_type_id
			);

		if ( ! $booking_type ) {
			return self::log_error(
				'tutorhelper_booking_type_not_found',
				__(
					'The selected Booking Type could not be found.',
					'tutorhelper-bookings'
				),
				array(
					'booking_type_id' =>
						$booking_type_id,
					'source' => $source,
				)
			);
		}

		if ( ! absint( $booking_type->is_active ) ) {
			return self::log_error(
				'tutorhelper_booking_type_inactive',
				__(
					'The selected Booking Type is not active.',
					'tutorhelper-bookings'
				),
				array(
					'booking_type_id' =>
						$booking_type_id,
					'source' => $source,
				)
			);
		}

		$student = get_user_by(
			'id',
			$student_user_id
		);

		if ( ! $student ) {
			return self::log_error(
				'tutorhelper_booking_student_not_found',
				__(
					'The selected student could not be found.',
					'tutorhelper-bookings'
				),
				array(
					'student_user_id' =>
						$student_user_id,
					'source' => $source,
				)
			);
		}

		$tutor = get_user_by(
			'id',
			$tutor_user_id
		);

		if ( ! $tutor ) {
			return self::log_error(
				'tutorhelper_booking_tutor_not_found',
				__(
					'The selected tutor could not be found.',
					'tutorhelper-bookings'
				),
				array(
					'tutor_user_id' =>
						$tutor_user_id,
					'source' => $source,
				)
			);
		}

		$timezone = self::sanitize_timezone(
			isset( $data['timezone'] )
				? $data['timezone']
				: wp_timezone_string()
		);

		$start = self::parse_to_utc(
			isset( $data['booking_start'] )
				? $data['booking_start']
				: '',
			$timezone
		);

		if ( is_wp_error( $start ) ) {
			return self::log_wp_error(
				$start,
				array(
					'booking_type_id' =>
						$booking_type_id,
					'source' => $source,
				)
			);
		}

		$end = self::parse_to_utc(
			isset( $data['booking_end'] )
				? $data['booking_end']
				: '',
			$timezone
		);

		if ( is_wp_error( $end ) ) {
			return self::log_wp_error(
				$end,
				array(
					'booking_type_id' =>
						$booking_type_id,
					'source' => $source,
				)
			);
		}

		$duration_minutes =
			self::calculate_duration_minutes(
				$start,
				$end
			);

		if ( is_wp_error( $duration_minutes ) ) {
			return self::log_wp_error(
				$duration_minutes,
				array(
					'booking_type_id' =>
						$booking_type_id,
					'source' => $source,
				)
			);
		}

		$is_duration_exception =
			TutorHelper_Booking_Types::
				is_duration_exception(
					$booking_type_id,
					$duration_minutes
				);

		if (
			is_wp_error(
				$is_duration_exception
			)
		) {
			return self::log_wp_error(
				$is_duration_exception,
				array(
					'booking_type_id' =>
						$booking_type_id,
					'source' => $source,
				)
			);
		}

		if (
			self::SOURCE_STUDENT === $source
			&& $is_duration_exception
		) {
			return self::log_error(
				'tutorhelper_student_booking_duration_mismatch',
				__(
					'Student bookings must use the standard Booking Type duration.',
					'tutorhelper-bookings'
				),
				array(
					'booking_type_id' =>
						$booking_type_id,
					'duration_minutes' =>
						$duration_minutes,
					'source' => $source,
				)
			);
		}

		$pricing =
			self::resolve_pricing(
				$student_user_id,
				$booking_type_id,
				$duration_minutes
			);

		if ( is_wp_error( $pricing ) ) {
			return self::log_wp_error(
				$pricing,
				array(
					'booking_type_id' =>
						$booking_type_id,

					'student_user_id' =>
						$student_user_id,

					'source' =>
						$source,
				)
			);
		}

		$price_minor_units =
			$pricing['price_minor_units'];


		$booking_start = $start->format(
			'Y-m-d H:i:s'
		);

		$booking_end = $end->format(
			'Y-m-d H:i:s'
		);

		if (
			self::has_conflict(
				$tutor_user_id,
				$booking_start,
				$booking_end
			)
		) {
			return self::log_error(
				'tutorhelper_booking_time_conflict',
				__(
					'The tutor already has a booking during this time.',
					'tutorhelper-bookings'
				),
				array(
					'tutor_user_id' =>
						$tutor_user_id,
					'booking_type_id' =>
						$booking_type_id,
					'source' => $source,
				)
			);
		}

		$location_type =
		self::sanitize_location_type(
			isset( $data['location_type'] )
				? $data['location_type']
				: $booking_type->location_type,
			$booking_type,
			$source
		);

		$location_details = isset(
			$data['location_details']
		)
			? sanitize_textarea_field(
				$data['location_details']
			)
			: sanitize_textarea_field(
				$booking_type->location_details
			);

		$booking_status =
		self::sanitize_status(
			isset( $data['status'] )
				? $data['status']
				: self::STATUS_CONFIRMED,
			$source
		);


		$currency =
			TutorHelper_Currency::get_currency();

		$payment_data =
			self::get_payment_data(
				$price_minor_units,
				$source,
				$data
			);

		$timestamp = current_time(
			'mysql',
			true
		);

		$insert_data = array(
			'booking_type_id' =>
				$booking_type_id,
			'student_user_id' =>
				$student_user_id,
			'tutor_user_id' =>
				$tutor_user_id,
			'student_name' =>
				sanitize_text_field(
					$student->display_name
				),
			'student_email' =>
				sanitize_email(
					$student->user_email
				),
			'booking_start' =>
				$booking_start,
			'booking_end' =>
				$booking_end,
			'price_minor_units' =>
				absint(
					$price_minor_units
				),
			'currency' =>
				$currency,
			'pricing_basis' =>
				$pricing['pricing_basis'],
			'pricing_exception' =>
				$is_duration_exception
					? 1
					: 0,
			'payment_method' =>
				$payment_data[
					'payment_method'
				],
			'payment_status' =>
				$payment_data[
					'payment_status'
				],
			'payment_provider' =>
				$payment_data[
					'payment_provider'
				],
			'payment_reference' =>
				$payment_data[
					'payment_reference'
				],
			'timezone' =>
				$timezone,
			'location_type' =>
				$location_type,
			'location_details' =>
				$location_details,
			'status' =>
				$booking_status,
			'source' =>
				$source,
			'notes' => isset(
				$data['notes']
			)
				? sanitize_textarea_field(
					$data['notes']
				)
				: '',
			'confirmation_sent_at' =>
				null,
			'cancelled_at' =>
				null,
			'cancellation_reason' =>
				null,
			'created_at' =>
				$timestamp,
			'updated_at' =>
				$timestamp,
		);

		$result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- TutorHelper stores bookings in a custom table; caching does not apply to this write.
			self::get_table_name(),
			$insert_data,
			self::get_insert_formats()
		);

		if ( false === $result ) {
			return self::log_error(
				'tutorhelper_booking_create_failed',
				$wpdb->last_error
					? $wpdb->last_error
					: __(
						'The booking could not be created.',
						'tutorhelper-bookings'
					),
				array(
					'booking_type_id' =>
						$booking_type_id,
					'student_user_id' =>
						$student_user_id,
					'tutor_user_id' =>
						$tutor_user_id,
					'source' => $source,
				),
				true
			);
		}

		$booking_id = absint(
			$wpdb->insert_id
		);

		TutorHelper_Logger::success(
			'Booking record created.',
			array(
				'booking_id' =>
					$booking_id,
				'booking_type_id' =>
					$booking_type_id,
				'student_user_id' =>
					$student_user_id,
				'tutor_user_id' =>
					$tutor_user_id,
				'duration_minutes' =>
					$duration_minutes,
				'price_minor_units' =>
					absint(
						$price_minor_units
					),
				'currency' =>
					$currency,
				'pricing_exception' =>
					$is_duration_exception,
				'payment_method' =>
					$payment_data[
						'payment_method'
					],
				'source' =>
					$source,
				'status' =>
					$booking_status,
			)
		);

		/**
		 * Fires after a booking record is created.
		 *
		 * @param int   $booking_id  Created booking ID.
		 * @param array $insert_data Database-ready booking data.
		 * @param int   $duration_minutes Derived duration.
		 */
		do_action(
			'tutorhelper_booking_created',
			$booking_id,
			$insert_data,
			$duration_minutes
		);

		return $booking_id;
	}


	/**
	 * Update mutable fields on one booking record.
	 *
	 * Student, tutor, source, payment references and creation metadata are
	 * deliberately preserved from the existing record.
	 *
	 * @param int   $booking_id Booking ID.
	 * @param array $data       Updated booking data.
	 *
	 * @return object|WP_Error Updated booking record.
	 */
	public static function update(
		$booking_id,
		$data
	) {
		global $wpdb;

		$booking_id = absint(
			$booking_id
		);

		$data = is_array( $data )
			? $data
			: array();

		if ( ! $booking_id ) {
			return self::log_update_error(
				'tutorhelper_booking_update_id_missing',
				__(
					'A booking is required.',
					'tutorhelper-bookings'
				)
			);
		}

		$existing = self::get_by_id(
			$booking_id
		);

		if ( ! $existing ) {
			return self::log_update_error(
				'tutorhelper_booking_update_not_found',
				__(
					'The booking could not be found.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' => $booking_id,
				)
			);
		}

		if (
			self::STATUS_CANCELLED
				=== sanitize_key(
					$existing->status
				)
		) {
			return self::log_update_error(
				'tutorhelper_booking_update_cancelled',
				__(
					'A cancelled booking cannot be edited.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' => $booking_id,
				)
			);
		}

		$booking_type_id = isset(
			$data['booking_type_id']
		)
			? absint(
				$data['booking_type_id']
			)
			: absint(
				$existing->booking_type_id
			);

		$booking_type =
			TutorHelper_Booking_Types::get_by_id(
				$booking_type_id
			);

		if ( ! $booking_type ) {
			return self::log_update_error(
				'tutorhelper_booking_type_not_found',
				__(
					'The selected Booking Type could not be found.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' =>
						$booking_id,

					'booking_type_id' =>
						$booking_type_id,
				)
			);
		}

		if (
			! absint(
				$booking_type->is_active
			)
		) {
			return self::log_update_error(
				'tutorhelper_booking_type_inactive',
				__(
					'The selected Booking Type is not active.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' =>
						$booking_id,

					'booking_type_id' =>
						$booking_type_id,
				)
			);
		}

		$timezone = self::sanitize_timezone(
			isset( $data['timezone'] )
				? $data['timezone']
				: $existing->timezone
		);

		$start = self::parse_to_utc(
			isset( $data['booking_start'] )
				? $data['booking_start']
				: '',
			$timezone
		);

		if ( is_wp_error( $start ) ) {
			return self::log_update_wp_error(
				$start,
				array(
					'booking_id' =>
						$booking_id,
				)
			);
		}

		$end = self::parse_to_utc(
			isset( $data['booking_end'] )
				? $data['booking_end']
				: '',
			$timezone
		);

		if ( is_wp_error( $end ) ) {
			return self::log_update_wp_error(
				$end,
				array(
					'booking_id' =>
						$booking_id,
				)
			);
		}

		$duration_minutes =
			self::calculate_duration_minutes(
				$start,
				$end
			);

		if (
			is_wp_error(
				$duration_minutes
			)
		) {
			return self::log_update_wp_error(
				$duration_minutes,
				array(
					'booking_id' =>
						$booking_id,
				)
			);
		}

		$is_duration_exception =
			TutorHelper_Booking_Types::
				is_duration_exception(
					$booking_type_id,
					$duration_minutes
				);

		if (
			is_wp_error(
				$is_duration_exception
			)
		) {
			return self::log_update_wp_error(
				$is_duration_exception,
				array(
					'booking_id' =>
						$booking_id,

					'booking_type_id' =>
						$booking_type_id,
				)
			);
		}

		$pricing =
			self::resolve_pricing(
				absint(
					$existing->student_user_id
				),
				$booking_type_id,
				$duration_minutes
			);

		if ( is_wp_error( $pricing ) ) {
			return self::log_update_wp_error(
				$pricing,
				array(
					'booking_id' =>
						$booking_id,

					'booking_type_id' =>
						$booking_type_id,
				)
			);
		}

		$price_minor_units =
			$pricing['price_minor_units'];

		$booking_start =
			$start->format(
				'Y-m-d H:i:s'
			);

		$booking_end =
			$end->format(
				'Y-m-d H:i:s'
			);

		if (
			self::has_conflict(
				absint(
					$existing->tutor_user_id
				),
				$booking_start,
				$booking_end,
				$booking_id
			)
		) {
			return self::log_update_error(
				'tutorhelper_booking_time_conflict',
				__(
					'The tutor already has another booking during this time.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' =>
						$booking_id,

					'tutor_user_id' =>
						absint(
							$existing
								->tutor_user_id
						),
				)
			);
		}

		/*
		* Tutor editing permissions are used for status and location
		* validation, while the booking's original source is preserved.
		*/
		$editor_source = isset(
			$data['editor_source']
		)
			&& self::SOURCE_TUTOR
				=== sanitize_key(
					$data['editor_source']
				)
					? self::SOURCE_TUTOR
					: sanitize_key(
						$existing->source
					);

		$location_type =
			self::sanitize_location_type(
				isset(
					$data['location_type']
				)
					? $data['location_type']
					: $existing
						->location_type,
				$booking_type,
				$editor_source
			);

		$booking_status =
			self::sanitize_status(
				isset( $data['status'] )
					? $data['status']
					: $existing->status,
				$editor_source
			);

		$update_data = array(
			'booking_type_id' =>
				$booking_type_id,

			'booking_start' =>
				$booking_start,

			'booking_end' =>
				$booking_end,

			'price_minor_units' =>
				absint(
					$price_minor_units
				),

			'currency' =>
				TutorHelper_Currency::
					get_currency(),

			'pricing_basis' =>
				$pricing['pricing_basis'],

			'pricing_exception' =>
				$is_duration_exception
					? 1
					: 0,

			'timezone' =>
				$timezone,

			'location_type' =>
				$location_type,

			'location_details' =>
				isset(
					$data['location_details']
				)
					? sanitize_textarea_field(
						$data[
							'location_details'
						]
					)
					: sanitize_textarea_field(
						$existing
							->location_details
					),

			'status' =>
				$booking_status,

			'notes' =>
				isset( $data['notes'] )
					? sanitize_textarea_field(
						$data['notes']
					)
					: sanitize_textarea_field(
						$existing->notes
					),

			'updated_at' =>
				current_time(
					'mysql',
					true
				),
		);

		$result = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- TutorHelper stores bookings in a custom table; this mutation must take effect immediately for subsequent booking checks.
			self::get_table_name(),
			$update_data,
			array(
				'id' => $booking_id,
			),
			array(
				'%d', // booking_type_id.
				'%s', // booking_start.
				'%s', // booking_end.
				'%d', // price_minor_units.
				'%s', // currency.
				'%s', // pricing_basis.
				'%d', // pricing_exception.
				'%s', // timezone.
				'%s', // location_type.
				'%s', // location_details.
				'%s', // status.
				'%s', // notes.
				'%s', // updated_at.
			),
			array( '%d' )
		);

		if ( false === $result ) {
			return self::log_update_error(
				'tutorhelper_booking_update_failed',
				$wpdb->last_error
					? $wpdb->last_error
					: __(
						'The booking could not be updated.',
						'tutorhelper-bookings'
					),
				array(
					'booking_id' =>
						$booking_id,
				),
				true
			);
		}

		$updated = self::get_by_id(
			$booking_id
		);

		if ( ! $updated ) {
			return self::log_update_error(
				'tutorhelper_booking_update_missing',
				__(
					'The updated booking could not be retrieved.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' =>
						$booking_id,
				)
			);
		}

		TutorHelper_Logger::success(
			'Booking record updated.',
			array(
				'booking_id' =>
					$booking_id,

				'booking_type_id' =>
					$booking_type_id,

				'tutor_user_id' =>
					absint(
						$existing
							->tutor_user_id
					),

				'student_user_id' =>
					absint(
						$existing
							->student_user_id
					),

				'duration_minutes' =>
					$duration_minutes,

				'price_minor_units' =>
					absint(
						$price_minor_units
					),

				'status' =>
					$booking_status,

				'location_type' =>
					$location_type,
			)
		);

		/**
		 * Fires after one booking is updated.
		 *
		 * @param int    $booking_id Booking ID.
		 * @param object $updated    Updated booking record.
		 * @param object $existing   Record before the update.
		 */
		do_action(
			'tutorhelper_booking_updated',
			$booking_id,
			$updated,
			$existing
		);

		return $updated;
	}


	/**
	 * Cancel one booking record.
	 *
	 * Business rules deciding who may cancel and when belong to the
	 * calling layer. This method performs the shared record mutation.
	 *
	 * @param int    $booking_id   Booking ID.
	 * @param string $reason       Optional cancellation reason.
	 * @param string $cancelled_by Cancellation actor: student or tutor.
	 *
	 * @return object|WP_Error Updated booking record.
	 */
	public static function cancel(
		$booking_id,
		$reason = '',
		$cancelled_by = ''
	) {
		global $wpdb;

		$booking_id =
			absint( $booking_id );

		if ( ! $booking_id ) {
			return self::log_update_error(
				'tutorhelper_booking_cancel_id_missing',
				__(
					'A booking is required.',
					'tutorhelper-bookings'
				)
			);
		}

		$cancelled_by =
			sanitize_key(
				$cancelled_by
			);

		if (
			! in_array(
				$cancelled_by,
				array(
					'student',
					'tutor',
				),
				true
			)
		) {
			$cancelled_by = '';
		}

		$existing =
			self::get_by_id(
				$booking_id
			);

		if ( ! $existing ) {
			return self::log_update_error(
				'tutorhelper_booking_cancel_not_found',
				__(
					'The booking could not be found.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' =>
						$booking_id,
				)
			);
		}

		if (
			self::STATUS_CANCELLED
			=== sanitize_key(
				$existing->status
			)
		) {
			return self::log_update_error(
				'tutorhelper_booking_already_cancelled',
				__(
					'This booking has already been cancelled.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' =>
						$booking_id,
				)
			);
		}

		$timestamp =
			current_time(
				'mysql',
				true
			);

		$result =
			$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- TutorHelper stores bookings in a custom table; this mutation must take effect immediately for subsequent booking checks.
				self::get_table_name(),
				array(
					'status' =>
						self::STATUS_CANCELLED,

					'cancelled_at' =>
						$timestamp,

					'cancellation_reason' =>
						sanitize_textarea_field(
							$reason
						),

					'cancelled_by' =>
						$cancelled_by,

					'updated_at' =>
						$timestamp,
				),
				array(
					'id' => $booking_id,
				),
				array(
					'%s', // status.
					'%s', // cancelled_at.
					'%s', // cancelled_by.
					'%s', // cancellation_reason.
					'%s', // updated_at.
				),
				array(
					'%d',
				)
			);

		if ( false === $result ) {
			return self::log_update_error(
				'tutorhelper_booking_cancel_failed',
				$wpdb->last_error
					? $wpdb->last_error
					: __(
						'The booking could not be cancelled.',
						'tutorhelper-bookings'
					),
				array(
					'booking_id' =>
						$booking_id,
				),
				true
			);
		}

		$updated =
			self::get_by_id(
				$booking_id
			);

		if ( ! $updated ) {
			return self::log_update_error(
				'tutorhelper_booking_cancel_missing',
				__(
					'The cancelled booking could not be retrieved.',
					'tutorhelper-bookings'
				),
				array(
					'booking_id' =>
						$booking_id,
				)
			);
		}

		TutorHelper_Logger::success(
			'Booking record cancelled.',
			array(
				'booking_id' =>
					$booking_id,

				'student_user_id' =>
					absint(
						$existing->student_user_id
					),

				'tutor_user_id' =>
					absint(
						$existing->tutor_user_id
					),

				'previous_status' =>
					sanitize_key(
						$existing->status
					),
			)
		);

		/**
		 * Fires after one booking is cancelled.
		 *
		 * @param int    $booking_id   Booking ID.
		 * @param object $updated      Cancelled booking record.
		 * @param object $existing     Record before cancellation.
		 * @param string $cancelled_by Cancellation actor.
		 */
		do_action(
			'tutorhelper_booking_cancelled',
			$booking_id,
			$updated,
			$existing,
			$cancelled_by
		);

		return $updated;
	}



    /**
	 * Log and return a booking error.
	 *
	 * @param string $code            Error code.
	 * @param string $message         Error message.
	 * @param array  $context         Safe log context.
	 * @param bool   $database_error  Whether this is a database failure.
	 *
	 * @return WP_Error
	 */
	private static function log_error(
		$code,
		$message,
		$context = array(),
		$database_error = false
	) {
		$context['error_code'] =
			sanitize_key( $code );

		if ( $database_error ) {
			TutorHelper_Logger::error(
				'Booking record creation failed.',
				$context
			);
		} else {
			TutorHelper_Logger::warning(
				'Booking record creation was rejected.',
				$context
			);
		}

		return new WP_Error(
			$code,
			$message
		);
	}

    /**
	 * Log and return an existing WP_Error.
	 *
	 * @param WP_Error $error   Error to return.
	 * @param array    $context Safe log context.
	 *
	 * @return WP_Error
	 */
	private static function log_wp_error(
		$error,
		$context = array()
	) {
		if ( ! is_wp_error( $error ) ) {
			return new WP_Error(
				'tutorhelper_booking_unknown_error',
				__(
					'An unknown booking error occurred.',
					'tutorhelper-bookings'
				)
			);
		}

		$context['error_code'] =
			$error->get_error_code();

		TutorHelper_Logger::warning(
			'Booking record creation was rejected.',
			$context
		);

		return $error;
	}

	/**
	 * Log and return a booking-update error.
	 *
	 * @param string $code           Error code.
	 * @param string $message        Error message.
	 * @param array  $context        Safe log context.
	 * @param bool   $database_error Whether this is a database error.
	 *
	 * @return WP_Error
	 */
	private static function log_update_error(
		$code,
		$message,
		$context = array(),
		$database_error = false
	) {
		$context['error_code'] =
			sanitize_key( $code );

		if ( $database_error ) {
			TutorHelper_Logger::error(
				'Booking record update failed.',
				$context
			);
		} else {
			TutorHelper_Logger::warning(
				'Booking record update was rejected.',
				$context
			);
		}

		return new WP_Error(
			$code,
			$message
		);
	}

	/**
	 * Log and return an existing update WP_Error.
	 *
	 * @param WP_Error $error   Error to return.
	 * @param array    $context Safe log context.
	 *
	 * @return WP_Error
	 */
	private static function log_update_wp_error(
		$error,
		$context = array()
	) {
		if ( ! is_wp_error( $error ) ) {
			return self::log_update_error(
				'tutorhelper_booking_update_unknown_error',
				__(
					'An unknown booking update error occurred.',
					'tutorhelper-bookings'
				),
				$context
			);
		}

		$context['error_code'] =
			$error->get_error_code();

		TutorHelper_Logger::warning(
			'Booking record update was rejected.',
			$context
		);

		return $error;
	}


    /**
	 * Return database formats for booking creation.
	 *
	 * The order must exactly match the insert data array.
	 *
	 * @return array
	 */
	private static function get_insert_formats() {
		return array(
			'%d', // booking_type_id.
			'%d', // student_user_id.
			'%d', // tutor_user_id.
			'%s', // student_name.
			'%s', // student_email.
			'%s', // booking_start.
			'%s', // booking_end.
			'%d', // price_minor_units.
			'%s', // currency.
			'%s', // pricing_basis.
			'%d', // pricing_exception.
			'%s', // payment_method.
			'%s', // payment_status.
			'%s', // payment_provider.
			'%s', // payment_reference.
			'%s', // timezone.
			'%s', // location_type.
			'%s', // location_details.
			'%s', // status.
			'%s', // source.
			'%s', // notes.
			'%s', // confirmation_sent_at.
			'%s', // cancelled_at.
			'%s', // cancellation_reason.
			'%s', // created_at.
			'%s', // updated_at.
		);
	}




}

