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

/**
 * Google Calendar Synchronization.
 */
class WC_Appointments_GCal {

	public const TOKEN_TRANSIENT_TIME = 3500;

	public const DAYS_OF_WEEK = [
		1 => 'monday',
		2 => 'tuesday',
		3 => 'wednesday',
		4 => 'thursday',
		5 => 'friday',
		6 => 'saturday',
		7 => 'sunday',
	];

	/**
	 * Class ID set by default.
	 */
	public string $id = 'gcal';

	/**
	 * oAuth URI set by default.
	 */
	public string $oauth_uri = 'https://accounts.google.com/o/oauth2/';

	/**
	 * Calendars URI set by default.
	 */
	public string $calendars_uri = 'https://www.googleapis.com/calendar/v3/calendars/';

	/**
	 * Calendars list set by default.
	 */
	public string $calendars_list = 'https://www.googleapis.com/calendar/v3/users/me/calendarList';

	/**
	 * API scope set by default.
	 */
	public string $api_scope = 'https://www.googleapis.com/auth/calendar';

	/**
	 * API Redirect URI.
	 */
	public string $redirect_uri;

	/**
	 * Option Client ID.
	 */
	public string|false $client_id = false;

	/**
	 * Option Client Secret.
	 */
	public string|false $client_secret = false;

	/**
	 * Option Calendar ID.
	 */
	public string|false $calendar_id = false;

	/**
	 * Option Debug.
	 *
	 * @var bool
	 */
	private bool $debug = false;

	/**
	 * OAuth2 token endpoint.
	 *
	 * @var string
	 */
	private string $token_uri;

	/**
	 * OAuth2 revoke endpoint.
	 *
	 * @var string
	 */
	private string $revoke_uri;

	/**
	 * Option Twoway.
	 *
	 * @var string
	 */
	private string $twoway = 'one_way';

	/**
	 * If the service is currently is a syncing operation with google.
	 *
	 * @var bool
	 */
	protected bool $syncing = false;

	/**
	 * If the service is currently is a syncing operation with google.
	 *
	 * @var bool
	 */
	protected bool $staff_syncing = false;

	/**
	 * @var WC_Appointments_GCal The single instance of the class
	 */
	protected static $_instance;

	/**
	 * User ID not set by default.
	 */
	private int|string|false $user_id = false;


	/**
	 * Forward-only sync window used during a run.
	 * Set by sync_from_gcal_locked and consumed by sync_from_gcal() new fetch path.
	 *
	 * @var array|null {start:int,end:int} or null
	 */
	protected ?array $current_sync_window = null;

	/**
	 * The calendar ID from which an incoming sync originated.
	 * Used to prevent syncing back to the same calendar while allowing cross-calendar sync.
	 *
	 * @var string
	 */
	protected string $incoming_sync_calendar_id = '';

	/**
	 * Main WC_Appointments_GCal Instance
	 */
	public static function instance(): self {
		if ( is_null( self::$_instance ) ) {
			self::$_instance = new self();
		}
		return self::$_instance;
	}

	/**
	 * Init and hook in the integration.
	 */
	public function __construct() {
		// API.
		$this->redirect_uri  = WC()->api_request_url( 'wc_appointments_oauth_redirect' );
		$this->client_id     = get_option( 'wc_appointments_gcal_client_id' );
		$this->client_secret = get_option( 'wc_appointments_gcal_client_secret' );
		$this->calendar_id   = get_option( 'wc_appointments_gcal_calendar_id' );
		$this->debug         = get_option( 'wc_appointments_gcal_debug' );
		$this->twoway        = get_option( 'wc_appointments_gcal_twoway' );

		// Upgrade to current OAuth endpoints (filterable for safety).
		$this->oauth_uri = apply_filters( 'wc_appointments_gcal_oauth_auth_url', 'https://accounts.google.com/o/oauth2/v2/auth' );
		$this->token_uri = apply_filters( 'wc_appointments_gcal_oauth_token_url', 'https://oauth2.googleapis.com/token' );
		$this->revoke_uri = apply_filters( 'wc_appointments_gcal_oauth_revoke_url', 'https://oauth2.googleapis.com/revoke' );

		// Oauth redirect.
		add_action( 'woocommerce_api_wc_appointments_oauth_redirect', [ $this, 'oauth_redirect' ] );
		add_action( 'admin_notices', [ $this, 'admin_notices' ] );

		// Run the schedule and Sync from GCal (ensure single run per calendar).
		add_action( 'action_scheduler_after_process_queue', [ $this, 'sync_from_gcal_locked' ] );

		// Appointment update actions.
		// Sync all statuses (except cancelled), but limit inside maybe_sync_to_gcal_from_status() function.
		$statuses = apply_filters(
		    'woocommerce_appointments_gcal_statuses',
		    [ 'unpaid', 'paid', 'pending-confirmation', 'confirmed', 'complete', 'in-cart' ],
		);
		foreach ( $statuses as $status ) {
			add_action( 'woocommerce_appointment_' . $status, [ $this, 'sync_unedited_appointment' ] );
		}

		// Remove from Gcal.
		add_action( 'woocommerce_appointment_cancelled', [ $this, 'sync_updated_appointment' ] );

		// Process edited appointment.
		add_action( 'woocommerce_appointment_process_meta', [ $this, 'sync_edited_appointment' ] );

		// Process edited appointment with staff changed.
		add_action( 'woocommerce_appointment_staff_changed', [ $this, 'maybe_sync_to_gcal_from_staff_change' ], 10, 4 );

		// Process rescheduled appointment.
		add_action( 'woocommerce_appointments_rescheduled_appointment', [ $this, 'sync_updated_appointment' ] );

		// Sync trashed/untrashed appointments.
		add_action( 'trashed_post', [ $this, 'sync_updated_appointment' ] );
		add_action( 'untrashed_post', [ $this, 'sync_updated_appointment' ] );

		// Sync availability to Gcal.
		add_action( 'woocommerce_before_appointments_availability_object_save', [ $this, 'sync_availability' ] ); // 'woocommerce_before_' . $object_type . '_object_save'
		add_action( 'woocommerce_appointments_before_delete_appointment_availability', [ $this, 'delete_availability' ] );
	}

	/**
	 * Get redirect_uri option.
	 */
    public function get_redirect_uri(): string {
        return $this->redirect_uri;
    }

	/**
	 * Get client_id option.
	 */
    public function get_client_id(): string|false {
        return $this->client_id;
    }

	/**
	 * Get client_secret option.
	 */
    public function get_client_secret(): string|false {
        return $this->client_secret;
    }

	/**
	 * Set calendar_id option.
	 */
	public function set_calendar_id( $option ): void {
        $this->calendar_id = $option;
    }

	/**
	 * Get calendar_id option.
	 */
    public function get_calendar_id(): string|false {
        return $this->calendar_id;
    }

	/**
	 * Set user_id option.
	 *
	 * @param int|string $option User ID.
	 */
	public function set_user_id( $option ): void {
		$site_calendar_id  = get_option( 'wc_appointments_gcal_calendar_id' );
		$staff_calendar_id = get_user_meta( $option, 'wc_appointments_gcal_calendar_id', true );

		// When synced to same calendar, sync to main calendar only.
		if ( $staff_calendar_id === $site_calendar_id ) {
			$option = 0;
		}

		$this->user_id = $option;
		$calendar_id   = get_user_meta( $option, 'wc_appointments_gcal_calendar_id', true );
		$calendar_id   = $calendar_id ?: get_option( 'wc_appointments_gcal_calendar_id' );
		$two_way       = get_user_meta( $option, 'wc_appointments_gcal_twoway', true );
		$two_way       = $two_way ?: get_option( 'wc_appointments_gcal_twoway' );

		$this->set_calendar_id( $calendar_id );
		$this->set_twoway( $two_way );
	}

	/**
	 * Get user_id option.
	 */
    public function get_user_id(): int|string|false {
        return $this->user_id;
    }

	/**
	 * Get debug option.
	 */
    public function get_debug(): bool {
        return $this->debug;
    }

	/**
	 * Set twoway option.
	 *
	 * @param string $option Two-way sync option ('one_way' or 'two_way').
	 */
	public function set_twoway( $option ): void {
        $this->twoway = $option;
    }

	/**
	 * Get twoway option.
	 */
    public function get_twoway(): string {
        return $this->twoway;
    }

	/**
	 * Get twoway option.
	 */
    public function is_twoway_enabled(): bool {
		$twoway_enabled = 'two_way' === $this->get_twoway();

		return apply_filters( 'woocommerce_appointments_gcal_sync_twoway_enabled', $twoway_enabled, $this );
    }

	/**
      * Display admin screen notices.
      */
     public function admin_notices(): void {
		// Get current screen.
		$screen          = function_exists( 'get_current_screen' ) ? get_current_screen() : '';
		$screen_id       = $screen ? $screen->id : '';
		$current_section = $_GET['section'] ?? '';

		// error_log( var_export( $current_section, true ) );

		$allowed_screens = [ 'user-edit', 'woocommerce_page_wc-settings' ];

		if ( in_array( $screen_id, $allowed_screens, true ) && isset( $_GET['wc_gcal_oauth'] ) ) {
			if ( 'success' == $_GET['wc_gcal_oauth'] ) {
				echo '<div class="updated fade"><p><strong>' . __( 'Google Calendar', 'woocommerce-appointments' ) . '</strong> ' . __( 'Account connected successfully!', 'woocommerce-appointments' ) . '</p></div>';
			} else {
				echo '<div class="error fade"><p><strong>' . __( 'Google Calendar', 'woocommerce-appointments' ) . '</strong> ' . __( 'Failed to connect to your account, please try again, if the problem persists, turn on Debug Log option and see what is happening.', 'woocommerce-appointments' ) . '</p></div>';
			}
		}

		if ( in_array( $screen_id, $allowed_screens, true ) && isset( $_GET['wc_gcal_logout'] ) ) {
			if ( 'success' == $_GET['wc_gcal_logout'] ) {
				echo '<div class="updated fade"><p><strong>' . __( 'Google Calendar', 'woocommerce-appointments' ) . '</strong> ' . __( 'Account disconnected successfully!', 'woocommerce-appointments' ) . '</p></div>';
			} else {
				echo '<div class="error fade"><p><strong>' . __( 'Google Calendar', 'woocommerce-appointments' ) . '</strong> ' . __( 'Failed to disconnect to your account, please try again, if the problem persists, turn on Debug Log option and see what is happening.', 'woocommerce-appointments' ) . '</p></div>';
			}
		}

		// Lightweight diagnostics panel (safe, optional).
		$show_status = apply_filters( 'wc_appointments_gcal_show_status_panel', true, $this );
		if ( $show_status && in_array( $screen_id, $allowed_screens, true ) && 'gcal' === $current_section ) {
			$uid         = $this->get_user_id() ? (int) $this->get_user_id() : 0;
			$last_sync   = 0 !== $uid ? get_user_meta( $uid, 'wc_appointments_gcal_last_sync', true ) : get_option( 'wc_appointments_gcal_last_sync' );
			$last_error  = 0 !== $uid ? get_user_meta( $uid, 'wc_appointments_gcal_last_error', true ) : get_option( 'wc_appointments_gcal_last_error' );
			$last_sync_t = (int) ( $last_sync['ts'] ?? 0 );
			$ok          = (bool) ( $last_sync['ok'] ?? false );
			$msg         = (string) ( $last_sync['msg'] ?? '' );
			$when        = 0 !== $last_sync_t ? sprintf( '%s, %s', date_i18n( wc_appointments_date_format(), $last_sync_t ), date_i18n( wc_appointments_time_format(), $last_sync_t ) ) : __( 'never', 'woocommerce-appointments' );

			$status_badge = $ok ? '<span style="color:#0a0;">' . esc_html__( 'OK', 'woocommerce-appointments' ) . '</span>' : '<span style="color:#a00;">' . esc_html__( 'Error', 'woocommerce-appointments' ) . '</span>';
			$error_msg    = $last_error ? '<br/>' . esc_html__( 'Last error:', 'woocommerce-appointments' ) . ' ' . esc_html( wp_strip_all_tags( $last_error ) ) : '';

			echo '<div class="notice notice-info"><p><strong>' . esc_html__( 'Google Calendar Connection Status', 'woocommerce-appointments' ) . ':</strong> ' . $status_badge . ' — ' . esc_html__( 'Last sync', 'woocommerce-appointments' ) . ': ' . esc_html( $when ) . ( '' !== $msg && '0' !== $msg ? ' — ' . esc_html( $msg ) : '' ) . $error_msg . '</p></div>';
		}
	}

	/**
	 * Get Access Token.
	 *
	 * @param  string $code Authorization code.
	 * @param  string|int $user_id
	 *
	 * @return string|false Access token or false on failure.
	 */
	public function get_access_token( $code = '', $user_id = '' ): string|false {
		$user_id = $user_id ?: '';
		$user_id = $this->get_user_id() && 0 !== $this->get_user_id() ? $this->get_user_id() : $user_id;

		// Check roles if user is shop staff.
		if ( $user_id ) {
			$user_meta = get_userdata( $user_id );
			if ( isset( $user_meta->roles ) && ! in_array( 'shop_staff', (array) $user_meta->roles, true ) ) {
				return false;
			}
		}

		// Load cached access token.
		$access_token = $user_id ? get_transient( 'wc_appointments_gcal_access_token_' . $user_id ) : get_transient( 'wc_appointments_gcal_access_token' );
		// Load refresh token.
		$refresh_token = $user_id ? get_user_meta( $user_id, 'wc_appointments_gcal_refresh_token', true ) : get_option( 'wc_appointments_gcal_refresh_token' );

		// If we have a valid cached token and no new code, reuse.
		if ( $access_token && ! $code ) {
			return $access_token;
		}

		// Exchange authorization code for tokens.
		if ( $code ) {
			$data = [
				'code'          => $code,
				'client_id'     => $this->get_client_id(),
				'client_secret' => $this->get_client_secret(),
				'redirect_uri'  => $this->get_redirect_uri(),
				'grant_type'    => 'authorization_code',
				'access_type'   => 'offline',
			];

			$response = wp_remote_post(
			    $this->token_uri,
			    [
					'body'      => http_build_query( $data ),
					'sslverify' => true,
					'timeout'   => 20,
				],
			);

			if ( is_wp_error( $response ) ) {
				$this->log_error( 'token_exchange_error', $response->get_error_message() );
				return false;
			}

			$code_http = (int) wp_remote_retrieve_response_code( $response );
			$payload   = json_decode( wp_remote_retrieve_body( $response ), true );

			if ( 200 !== $code_http || empty( $payload['access_token'] ) ) {
				$reason = $payload['error'] ?? 'unknown';
				$this->log_error( 'token_exchange_http_' . $code_http, $reason );
				return false;
			}

			$access_token  = $payload['access_token'];
			$expires_in    = (int) ( $payload['expires_in'] ?? self::TOKEN_TRANSIENT_TIME );
			$refresh_token = isset( $payload['refresh_token'] ) && $payload['refresh_token'] ? $payload['refresh_token'] : $refresh_token;

			// Cache access token slightly before expiry.
			$ttl = max( 60, $expires_in - 60 );
			if ( $user_id ) {
				set_transient( 'wc_appointments_gcal_access_token_' . $user_id, $access_token, $ttl );
				if ( $refresh_token ) {
					update_user_meta( $user_id, 'wc_appointments_gcal_refresh_token', $refresh_token );
				}
			} else {
				set_transient( 'wc_appointments_gcal_access_token', $access_token, $ttl );
				if ( $refresh_token ) {
					update_option( 'wc_appointments_gcal_refresh_token', $refresh_token, false );
				}
			}

			return $access_token;
		}

		// Refresh access token using refresh_token.
		if ( ! $code && $refresh_token ) {
			$data = [
				'client_id'     => $this->get_client_id(),
				'client_secret' => $this->get_client_secret(),
				'refresh_token' => $refresh_token,
				'grant_type'    => 'refresh_token',
				'access_type'   => 'offline',
			];

			$response = wp_remote_post(
			    $this->token_uri,
			    [
					'body'      => http_build_query( $data ),
					'sslverify' => true,
					'timeout'   => 20,
				],
			);

			if ( is_wp_error( $response ) ) {
				$this->log_error( 'token_refresh_error', $response->get_error_message() );
				return false;
			}

			$code_http = (int) wp_remote_retrieve_response_code( $response );
			$payload   = json_decode( wp_remote_retrieve_body( $response ), true );

			if ( 200 !== $code_http || empty( $payload['access_token'] ) ) {
				$reason = $payload['error'] ?? 'unknown';
				// Cleanly deauthorize on invalid_grant to avoid loops.
				if ( in_array( $reason, [ 'invalid_grant', 'unauthorized_client' ], true ) ) {
					$this->handle_deauthorize( $user_id );
				}
				$this->log_error( 'token_refresh_http_' . $code_http, $reason );
				return false;
			}

			$access_token = $payload['access_token'];
			$expires_in   = (int) ( $payload['expires_in'] ?? self::TOKEN_TRANSIENT_TIME );
			$ttl          = max( 60, $expires_in - 60 );

			if ( $user_id ) {
				set_transient( 'wc_appointments_gcal_access_token_' . $user_id, $access_token, $ttl );
			} else {
				set_transient( 'wc_appointments_gcal_access_token', $access_token, $ttl );
			}

			return $access_token;
		}

		return false;
	}

	/**
	 * Locking wrapper around sync to avoid overlapping runs and to expose a forward-only window.
	 * Delegates to existing sync_from_gcal() to preserve behavior.
	 */
	public function sync_from_gcal_locked(): void {
		$enabled = apply_filters( 'wc_appointments_gcal_locking_enabled', true, $this );
		if ( ! $enabled || ! method_exists( $this, 'sync_from_gcal' ) ) {
			if ( method_exists( $this, 'sync_from_gcal' ) ) {
				$this->sync_from_gcal();
			}
			return;
		}

		if ( ! $this->acquire_sync_lock() ) {
			$this->log_debug( 'sync_skipped_locked', 'Another sync is in progress for this calendar.' );
			return;
		}

		try {
			// Expose the forward-only window to internal calls if needed.
			$this->current_sync_window = $this->get_forward_sync_window(); // @phpstan-ignore-line
			$this->sync_from_gcal();
		} catch ( \Throwable $e ) {
			// Defensive: some upstream code paths embed a raw JSON response in the exception message.
			// If the exception message is a valid Google Calendar response (contains 'items' array), treat it as non-fatal
			// to avoid breaking sync on benign/informational payloads like empty incremental updates.
			$msg = trim( $e->getMessage() );
			$decoded = json_decode( $msg, true );

			if ( is_array( $decoded ) && array_key_exists( 'items', $decoded ) ) {
				// Valid Google response structure detected in exception message - treat as non-fatal.
				// Log at debug level and record a successful (but informational) sync status.
				$event_count = is_array( $decoded['items'] ) ? count( $decoded['items'] ) : 0;
				$this->log_debug( 'sync_handled_json_exception', sprintf( 'Handled Google Calendar response in exception message (%d event(s)).', $event_count ) );
				/* translators: %d event count */
				$this->mark_last_sync_status( true, sprintf( __( 'Processed %d event(s) from exception payload.', 'woocommerce-appointments' ), $event_count ) );
			} else {
				// Not a valid Google response structure — rethrow.
				throw $e;
			}
		} finally {
			unset( $this->current_sync_window );
			$this->release_sync_lock();
		}
	}

	/**
	 * Forward-only sync window [now, horizon] based on availability cache horizon.
	 *
	 * @return array{start:int,end:int}
	 */
	protected function get_forward_sync_window(): array {
		$start = (int) current_time( 'timestamp' );
		$end   = $this->get_cache_horizon_ts();

		$start = apply_filters( 'wc_appointments_gcal_forward_window_start', $start, $this );
		$end   = apply_filters( 'wc_appointments_gcal_forward_window_end', $end, $this );

		return [ 'start' => $start, 'end' => $end ];
	}

	/**
     * Compute availability horizon timestamp without calling private methods on other classes.
     */
    protected function get_cache_horizon_ts(): int {
		// Use the configurable horizon from admin settings.
		if ( function_exists( 'wc_appointments_get_cache_horizon_months' ) ) {
			$months = wc_appointments_get_cache_horizon_months();
			return (int) strtotime( '+' . $months . ' months UTC' );
		}
		// Fallback to 3 months if helper function is not available.
		return strtotime( '+3 months UTC' );
	}

	/**
	 * Acquire a transient lock per calendar/user to avoid overlapping syncs.
	 *
	 * @return bool
	 */
	protected function acquire_sync_lock(): bool {
		$key = $this->get_sync_lock_key();
		if ( get_transient( $key ) ) {
			return false;
		}
		$ttl = (int) apply_filters( 'wc_appointments_gcal_lock_ttl', 60, $this ); // seconds
		return (bool) set_transient( $key, 1, $ttl );
	}

	/**
	 * Release sync lock.
	 */
	protected function release_sync_lock(): void {
		delete_transient( $this->get_sync_lock_key() );
	}

	/**
     * Build per-calendar/user sync lock key.
     */
    protected function get_sync_lock_key(): string {
		$uid = $this->get_user_id() ? (int) $this->get_user_id() : 0;
		$cid = sanitize_key( (string) $this->get_calendar_id() );
		return 'wc_appointments_gcal_lock_' . md5( $uid . '|' . $cid );
	}

	/**
	 * Acquire a short-lived lock for this calendar/user to avoid overlapping syncs.
	 */
	protected function acquire_lock( $key ): bool {
		$ttl = (int) apply_filters( 'wc_appointments_gcal_lock_ttl', 60, $this ); // seconds
		if ( get_transient( $key ) ) {
			return false;
		}
		// Minimize race: set if not exists.
		return set_transient( $key, 1, $ttl );
	}

	/**
	 * Release lock.
	 */
	protected function release_lock( $key ): void {
		delete_transient( $key );
	}

	/**
	 * Build lock key per calendar/user context.
	 */
	protected function get_calendar_lock_key(): string {
		$uid = $this->get_user_id() ? (int) $this->get_user_id() : 0;
		$cid = sanitize_key( (string) $this->get_calendar_id() );
		return 'wc_appointments_gcal_lock_' . md5( $uid . '|' . $cid );
	}

	/**
	 * Handle deauthorization: clear tokens safely.
	 *
	 * @param int|string $user_id
	 */
	protected function handle_deauthorize( $user_id = '' ): void {
		if ( $user_id ) {
			delete_transient( 'wc_appointments_gcal_access_token_' . $user_id );
			delete_user_meta( $user_id, 'wc_appointments_gcal_refresh_token' );
		} else {
			delete_transient( 'wc_appointments_gcal_access_token' );
			delete_option( 'wc_appointments_gcal_refresh_token' );
		}
	}

	/**
	 * Persist incremental sync token.
	 *
	 * @param string     $sync_token
	 * @param int|string $user_id
	 */
	protected function set_sync_token( $sync_token, $user_id = '' ): void {
		$key = $user_id ? 'wc_appointments_gcal_sync_token_' . (int) $user_id : 'wc_appointments_gcal_sync_token';
		update_option( $key, (string) $sync_token, false );
	}

	/**
     * Retrieve incremental sync token.
     *
     * @param int|string $user_id
     */
    protected function get_sync_token( $user_id = '' ): string {
		$key = $user_id ? 'wc_appointments_gcal_sync_token_' . (int) $user_id : 'wc_appointments_gcal_sync_token';
		return (string) get_option( $key, '' );
	}

	/**
     * Fetch events using incremental syncToken when available, falling back to a forward-only window.
     * Handles pagination and 410 invalid token recovery.
     *
     * @param array  $window      ['start'=>int,'end'=>int] UTC timestamps
     * @param string $sync_token  Existing syncToken or empty string
     * @return array{events:array,nextSyncToken:string}
     */
    protected function fetch_events_delta_or_window( string $access_token, array $window, $sync_token = '' ): array {
		$base_url = 'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode( $this->get_calendar_id() ) . '/events';

		$enable_delta = (bool) apply_filters( 'wc_appointments_gcal_incremental_sync_enabled', true, $this );

		// Build minimal parameter set based on whether we're using incremental sync or a forward-only window.
		if ( $enable_delta && $sync_token ) {
			// Incremental sync: Use syncToken + maxResults + showDeleted.
			// Do NOT include singleEvents/orderBy as these can trigger 400 Bad Request with syncToken.
			// showDeleted is important to capture cancelled events so we can delete corresponding availability rules.
			$params = [
				'syncToken'   => $sync_token,
				'maxResults'  => (int) apply_filters( 'wc_appointments_gcal_max_results', 200, $this ),
				'showDeleted' => 'true',
			];
		} else {
			// Forward-only window: Use timeMin/timeMax without singleEvents to get recurring events as RRULE.
			// Use 'Z' suffix for UTC timestamps (Google prefers this over +00:00 offset).
			// singleEvents=false means recurring events are returned as a single event with recurrence rules
			$params = [
				'maxResults'   => (int) apply_filters( 'wc_appointments_gcal_max_results', 200, $this ),
				'singleEvents' => 'false',
				'showDeleted'  => 'true',
				'timeMin'      => gmdate( 'Y-m-d\TH:i:s\Z', $window['start'] ),
				'timeMax'      => gmdate( 'Y-m-d\TH:i:s\Z', $window['end'] ),
			];
		}

		$headers = [
			'Authorization' => 'Bearer ' . $access_token,
		];

		$events     = [];
		$page_token = '';
		$next_sync  = '';

		$max_pages = (int) apply_filters( 'wc_appointments_gcal_max_pages_per_run', 10, $this );

		for ( $i = 0; $i < $max_pages; $i++ ) {
			$query = $params;
			if ( '' !== $page_token && '0' !== $page_token ) {
				$query['pageToken'] = $page_token;
			}
			$url = add_query_arg( $query, $base_url );

			// Debug: log the full request URL + query when debug is enabled to help diagnose 400 errors.
			if ( $this->get_debug() ) {
				$this->log_debug( 'events_fetch_request', 'Fetching events from: ' . $url );
			}

			$response = wp_remote_get(
			    $url,
			    [
					'headers'   => $headers,
					'sslverify' => true,
					'timeout'   => 20,
				],
			);

			if ( is_wp_error( $response ) ) {
				$this->log_error( 'events_fetch_error', $response->get_error_message() );
				break;
			}

			$http_code = (int) wp_remote_retrieve_response_code( $response );
			$body      = wp_remote_retrieve_body( $response );

			// Handle invalid sync token (410 Gone): clear token and return so caller can retry with window.
			if ( 410 === $http_code ) {
				$this->log_debug( 'events_fetch_410', 'Sync token invalid, clearing and falling back to forward-only window.' );
				$this->set_sync_token( '', $this->get_user_id() ? (int) $this->get_user_id() : '' );
				return [ 'events' => [], 'nextSyncToken' => '' ];
			}

			// Handle 400 Bad Request specially: Google may return 400 when a supplied syncToken is malformed/stale,
			// or other badRequest conditions. Inspect the body and treat known Google errors as recoverable.
			if ( 400 === $http_code ) {
				$decoded = json_decode( $body, true );

				// If this looks like a Google API error payload, handle gracefully.
				if ( is_array( $decoded ) && isset( $decoded['error']['errors'][0]['reason'] ) ) {
					$reason = (string) ( $decoded['error']['errors'][0]['reason'] ?? '' );

					// When using a syncToken, treat 400/badRequest like an invalid token: clear stored token and retry with window.
					if ( $sync_token ) {
						$this->log_debug( 'events_fetch_400_sync_token', sprintf( 'Received 400 (%s) with syncToken, clearing token and falling back to forward-only window.', $reason ) );
						$this->set_sync_token( '', $this->get_user_id() ? (int) $this->get_user_id() : '' );
						return [ 'events' => [], 'nextSyncToken' => '' ];
					}

					// Without a syncToken, this is likely a Google-side badRequest (e.g. invalid parameters).
					// Log at debug level and return empty so caller can continue without throwing.
					$this->log_debug( 'events_fetch_400', 'Google Calendar returned 400 Bad Request: ' . substr( $body, 0, 1000 ) );
					return [ 'events' => [], 'nextSyncToken' => '' ];
				}

				// Non-JSON or unexpected body - fallback to error logging below.
			}

			// Any other non-200 responses are considered errors.
			// Some API clients return 400 Bad Request when the supplied syncToken is malformed/stale.
			// Treat 400 while using a syncToken as an invalid-token case so the caller can retry with a forward-only window.
			if ( 400 === $http_code && $sync_token ) {
				$this->log_debug( 'events_fetch_400_sync_token', 'Received 400 Bad Request when using syncToken; clearing token and falling back to forward-only window.' );
				$this->set_sync_token( '', $this->get_user_id() ? (int) $this->get_user_id() : '' );
				return [ 'events' => [], 'nextSyncToken' => '' ];
			}

			if ( 200 !== $http_code ) {
				$this->log_error( 'events_fetch_http_' . $http_code, substr( $body, 0, 200 ) );
				break;
			}

			$payload = json_decode( $body, true );
			if ( ! is_array( $payload ) ) {
				$this->log_error( 'events_fetch_decode', 'Invalid JSON response.' );
				break;
			}

			if ( ! empty( $payload['items'] ) && is_array( $payload['items'] ) ) {
				$events = array_merge( $events, $payload['items'] );
			}

			$page_token = (string) ( $payload['nextPageToken'] ?? '' );
			$next_sync  = (string) ( $payload['nextSyncToken'] ?? $next_sync );

			if ( '' === $page_token || '0' === $page_token ) {
				break;
			}
		}

		return [ 'events' => $events, 'nextSyncToken' => $next_sync ];
	}

	/**
     * Minimal debug logger honoring plugin debug setting.
     */
    protected function log_debug( string $handle, string $message ): void {
		if ( ! $this->get_debug() ) {
			return;
		}
		if ( function_exists( 'wc_get_logger' ) ) {
			wc_get_logger()->debug( '[' . $handle . '] ' . $message, [ 'source' => 'wc-appointments-gcal' ] );
		}
	}

	/**
     * Minimal error logger honoring plugin debug setting.
     */
    protected function log_error( string $handle, string $message ): void {
		if ( ! $this->get_debug() ) {
			return;
		}
		if ( function_exists( 'wc_get_logger' ) ) {
			wc_get_logger()->error( '[' . $handle . '] ' . $message, [ 'source' => 'wc-appointments-gcal' ] );
		}
	}

	/**
	 * OAuth Logout.
	 *
	 * @return bool
	 */
	protected function oauth_logout( $user_id = '' ): ?bool {
		$user_id = $user_id ?: '';
		$user_id = $this->get_user_id() ?: $user_id;

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			wc_add_appointment_log( $this->id, 'Disconnecting from the Google Calendar app...' ); #debug
		}

		// Get the refresh token.
		$refresh_token = $user_id ? get_user_meta( $user_id, 'wc_appointments_gcal_refresh_token', true ) : get_option( 'wc_appointments_gcal_refresh_token' );

		if ( $refresh_token ) {
		$params = [
			'body'      => http_build_query( [ 'token' => $refresh_token ] ),
			'sslverify' => true,
			'timeout'   => 20,
			'headers'   => [
				'Content-Type' => 'application/x-www-form-urlencoded',
			],
		];

		$response = wp_remote_post( $this->revoke_uri, $params );
        if (! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response )) {
            // Delete tokens.
            if ( $user_id ) {
					delete_user_meta( $user_id, 'wc_appointments_gcal_refresh_token' );
					delete_transient( 'wc_appointments_gcal_access_token_' . $user_id );
				} else {
					delete_option( 'wc_appointments_gcal_refresh_token' );
					delete_transient( 'wc_appointments_gcal_access_token' );
				}
            // Debug.
            if ( 'yes' === $this->get_debug() ) {
					wc_add_appointment_log( $this->id, 'Successfully disconnected from the Google Calendar app' ); #debug
				}
            return true;
        }

		if ('yes' === $this->get_debug()) {
            // Debug.
            wc_add_appointment_log(
                $this->id,
                'Error while disconnecting from the Google Calendar app: ' . var_export( $response['response'], true ),
            );
            #debug
        }
		}

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			wc_add_appointment_log( $this->id, 'Failed to disconnect from the Google Calendar app' ); #debug
		}
        return null;
	}

	/**
     * Process the oauth redirect.
     */
    public function oauth_redirect(): void {
		if ( ! current_user_can( 'manage_appointments' ) ) {
			wp_die( __( 'Permission denied!', 'woocommerce-appointments' ) );
		}

		// User ID passed.
		if ( isset( $_GET['state'] ) ) {

			$user_id       = absint( $_GET['state'] );
			$admin_url     = admin_url( 'user-edit.php' );
			$redirect_args = [
				'user_id' => $_GET['state'],
			];

		} else {

			$user_id       = '';
			$admin_url     = admin_url( 'admin.php' );
			$redirect_args = [
				'page'    => 'wc-settings',
				'tab'     => 'appointments',
				'section' => $this->id,
			];

		}

		// OAuth.
		if ( isset( $_GET['code'] ) ) {
			$code         = sanitize_text_field( $_GET['code'] );
			$access_token = $this->get_access_token( $code, $user_id );

			if ( ! $access_token ) {
				$redirect_args['wc_gcal_oauth'] = 'fail';

				wp_safe_redirect( add_query_arg( $redirect_args, $admin_url ), 301 );
				exit;
			}
            $redirect_args['wc_gcal_oauth'] = 'success';
            wp_safe_redirect( add_query_arg( $redirect_args, $admin_url ), 301 );
            exit;
		}

		// Error.
		if ( isset( $_GET['error'] ) ) {
			$redirect_args['wc_gcal_oauth'] = 'fail';

			wp_safe_redirect( add_query_arg( $redirect_args, $admin_url ), 301 );
			exit;
		}

		// Logout.
		if ( isset( $_GET['logout'] ) ) {
			$logout                          = $this->oauth_logout( $user_id );
			$redirect_args['wc_gcal_logout'] = ( $logout ?? false ) ? 'success' : 'fail';

			wp_safe_redirect( add_query_arg( $redirect_args, $admin_url ), 301 );
			exit;
		}

		wp_die( __( 'Invalid request!', 'woocommerce-appointments' ) );
	}

	/**
	 * Get user calendars.
	 *
	 * @return array Calendar list
	 */
	public function get_calendars(): ?array {
		// Get all Google Calendars.
		$google_calendars = [];

		// Check if Authorized.
		$access_token = $this->get_access_token();
		if ( ! $access_token ) {
			return null;
		}

		// Connection params.
		$params = [
			'method'    => 'GET',
			'sslverify' => false,
			'timeout'   => 60,
			'headers'   => [
				'Content-Type'  => 'application/json',
				'Authorization' => 'Bearer ' . $access_token,
			],
		];

		$response = wp_safe_remote_post( $this->calendars_list, $params );

		if ( ! is_wp_error( $response ) && 200 == $response['response']['code'] && 'OK' == $response['response']['message'] ) {
			// Get response data.
			$response_data = json_decode( $response['body'], true );

			// List calendars.
			if ( is_array( $response_data['items'] ) && (isset($response_data['items']) && [] !== $response_data['items']) ) {
				foreach ( $response_data['items'] as $data ) {
					$google_calendars[ $data['id'] ] = $data['summary'];
				}
			}
		}

		return $google_calendars;
	}

	/**
	 * Check if Google Calendar settings are supplied.
	 *
	 * @return bool True is calendar is set, false otherwise.
	 */
	public function is_calendar_set(): bool {
		$client_id     = $this->get_client_id();
		$client_secret = $this->get_client_secret();
		$calendar_id   = $this->get_calendar_id();

		return ! empty( $client_id ) && ! empty( $client_secret ) && ! empty( $calendar_id );
	}

	/**
	 * Makes an http request to the Google Calendar API.
	 *
	 * @param  string     $api_url  API Url to make the request against.
	 * @param  array      $params   Array of parameters that will be used when making the request.
	 * @param  string|int $staff_id Staff ID if request is for specific staff.
	 *
	 * @version       3.5.6
	 * @since         3.5.6
	 * @return array|\WP_Error Response object from the request.
	 */
	protected function make_gcal_request( string $api_url, $params = [], $staff_id = '' ) {
		if ( ! isset( $api_url ) ) {
			return;
		}

		// Check if Authorized.
		$access_token = $this->get_access_token( '', $staff_id );
		if ( ! $access_token ) {
			return;
		}

		// Connection params.
		$params['method']    = strtoupper( $params['method'] ?? 'GET' );
		$params['sslverify'] = false;
		$params['timeout']   = 60;
		$params['headers']   = [
			'Content-Type'  => 'application/json',
			'Authorization' => 'Bearer ' . $access_token,
		];

		// No real use for now.
		if ( isset( $params['querystring'] ) && is_array( $params['querystring'] ) ) {
			$api_url .= '?' . wp_json_encode( http_build_query( $params['querystring'] ), JSON_UNESCAPED_SLASHES );
		}

		if ( in_array( $params['method'], [ 'GET', 'DELETE' ] ) ) {
			unset( $params['body'] );
		}

		// Filter the gCal request.
		$params = apply_filters( 'woocommerce_appointments_gcal_sync_parameters', $params, $api_url, $staff_id );

		$response = wp_safe_remote_request( $api_url, $params );

		// 200 = ok
		// 204 = deleted
		if ( ! is_wp_error( $response ) && 'OK' == $response['response']['message']
			&& in_array( $response['response']['code'], [ 200, 204 ] )
		) {
			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				#wc_add_appointment_log( $this->id, 'Successful Google Calendar request for ' . $api_url . ': ' . var_export( $response, true ) ); #debug
			}
		} elseif ( 410 === $response['response']['code'] ) {
			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, 'Attempting to delete event that does not exist any more' ); #debug
			}

		// Debug.
		} elseif ( 'yes' === $this->get_debug() ) {
			wc_add_appointment_log( $this->id, 'Error while making Google Calendar request for ' . $api_url . ': ' . var_export( $response, true ) ); #debug
		}

		return $response;
	}

	/**
	 * Acquire an appointment-scoped event lock to avoid duplicate creates.
	 * Uses add_option for atomicity; stores an expiry to prevent deadlocks.
	 *
	 * @param int    $appointment_id Appointment ID
	 * @param string $scope          Lock scope key (e.g. "site|<calendar>", "staff_<id>|<calendar>")
	 * @return bool                  True if lock acquired; false if already locked
	 */
	protected function acquire_event_lock( $appointment_id, string $scope ): bool {
		$option   = $this->get_event_lock_option_name( $appointment_id, $scope );
		$now      = time();
		$ttl      = (int) apply_filters( 'wc_appointments_gcal_event_lock_ttl', 30, $this ); // seconds
		$expires  = $now + max( 1, $ttl );
		$existing = get_option( $option, [] );

		// Clear stale lock if expired.
		if ( is_array( $existing ) && isset( $existing['expires'] ) && (int) $existing['expires'] < $now ) {
			delete_option( $option );
		}

		// Atomic acquire via add_option. Autoload disabled.
		return (bool) add_option( $option, [ 'expires' => $expires ], '', 'no' );
	}

	/**
	 * Release appointment-scoped event lock.
	 *
	 * @param int    $appointment_id Appointment ID
	 * @param string $scope          Scope key used when acquiring
	 */
	protected function release_event_lock( $appointment_id, string $scope ): void {
		delete_option( $this->get_event_lock_option_name( $appointment_id, $scope ) );
	}

	/**
     * Build appointment event lock option name.
     *
     * @param int    $appointment_id
     */
    protected function get_event_lock_option_name( $appointment_id, string $scope ): string {
		$aid = (int) $appointment_id;
		return 'wc_appt_gcal_event_lock_' . md5( $aid . '|' . $scope );
	}

	/**
     * Is edited from post.php's meta box.
     */
    public function is_edited_from_meta_box(): bool {
		return (
			! empty( $_POST['wc_appointments_details_meta_box_nonce'] )
			&&
			wp_verify_nonce( $_POST['wc_appointments_details_meta_box_nonce'], 'wc_appointments_details_meta_box' )
		);
	}

	/**
     * Sync Appointment with GCal when appointment is edited/updated/saved manually.
     *
     * @param  int $appointment_id Appointment ID
     */
    public function sync_edited_appointment( $appointment_id ): void {
		if ( ! $this->is_edited_from_meta_box() ) {
			return;
		}

		$this->maybe_sync_to_gcal_from_status( $appointment_id );
	}

	/**
     * Sync Appointment with GCal when appointment is NOT edited/updated/saved manually.
     * New appoiintments should also sync this way.
     *
     * @param  int $appointment_id Appointment ID
     */
    public function sync_unedited_appointment( $appointment_id ): void {
		if ( $this->is_edited_from_meta_box() ) {
			return;
		}

		$this->maybe_sync_to_gcal_from_status( $appointment_id );
	}

	/**
     * Sync Appointment with GCal when appointment changes status or anything else.
     *
     * @param  int $appointment_id Appointment ID
     */
    public function sync_updated_appointment( $appointment_id ): void {
		$this->maybe_sync_to_gcal_from_status( $appointment_id );
	}

	/**
     * Maybe remove / sync appointment based on appointment status.
     *
     * @param int $appointment_id Appointment ID
     */
    public function maybe_sync_to_gcal_from_status( $appointment_id ): void {
		global $wpdb;

		// Skip if currently syncing from gCal to avoid loop-back.
		if ( $this->syncing ) {
			return;
		}

		// Check if Authorized.
		$access_token = $this->get_access_token();
		if ( ! $access_token ) {
			return;
		}

		$status = $wpdb->get_var( $wpdb->prepare( "SELECT post_status FROM $wpdb->posts WHERE post_type = 'wc_appointment' AND ID = %d", $appointment_id ) );

		if ( in_array( $status, [ 'cancelled', 'trash' ] ) ) {
			$this->remove_from_gcal( $appointment_id, false, false, true ); // true - sync other staff as well.
		} elseif ( in_array( $status, apply_filters( 'woocommerce_appointments_gcal_sync_statuses', [ 'confirmed', 'paid', 'complete' ] ) ) ) {
			$this->sync_to_gcal( $appointment_id, false, false, true ); // true - sync other staff as well.
		} elseif ( 'unpaid' === $status ) { // Sync Cash on Delivery appointments.
			$order_id = WC_Appointment_Data_Store::get_appointment_order_id( $appointment_id );
			$order    = wc_get_order( $order_id );
			if (is_a($order, 'WC_Order') && 'cod' === $order->get_payment_method()) {
                $this->sync_to_gcal( $appointment_id, false, false, true );
                // true - sync other staff as well.
            }
		}
	}

	/**
     * Maybe remove / sync appointment based on appointment staff change.
     *
     * @param int $appointment_id Appointment ID
     */
    public function maybe_sync_to_gcal_from_staff_change( $from, $to, $appointment_id, $appointment ): void {
		// Only work with staff changes, when appointments are edited manually
		// and are already existing to avoid infinite loops with calls to gcal.
		if ( ! $this->is_edited_from_meta_box() ) {
			return;
		}

		// Check if Authorized.
		$access_token = $this->get_access_token();

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			#wc_add_appointment_log( $this->id, 'Manage token checks: ' . var_export( $access_token, true ) );
		}
		if ( ! $access_token ) {
			return;
		}

		// Check if appointment exists.
		$appointment = get_wc_appointment( $appointment_id );
		if ( ! $appointment ) {
			return;
		}

		$this->staff_syncing = true;

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			#wc_add_appointment_log( $this->id, 'Manage staff for appointment #' . $appointment_id );
		}

		// Get staff that was removed.
		#$to = [4, 5, 6, 7, 8];
		#$from = [1, 2, 3, 4, 5];
		$removed_staff_ids = array_diff( $from, $to );

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			#wc_add_appointment_log( $this->id, 'Removed staff IDs :' . var_export( $removed_staff_ids, true ) );
		}
		if ( [] !== $removed_staff_ids ) {
			foreach ( $removed_staff_ids as $removed_staff_id ) {
				$calendar_id       = get_user_meta( $removed_staff_id, 'wc_appointments_gcal_calendar_id', true );
				$removed_staff_calendar_id = $calendar_id ?: '';

				// Staff must have calendar ID set.
				if ( $removed_staff_calendar_id ) {
					// Switch calendar to staff for new sync.
					$this->set_calendar_id( $removed_staff_calendar_id );
					$this->set_user_id( $removed_staff_id ); #reset to staff calendar sync.

					// Debug.
					if ( 'yes' === $this->get_debug() ) {
						#wc_add_appointment_log( $this->id, 'Removing appointment #' . $appointment_id . ' for staff #' . $removed_staff_id . ' from Google Calendar: ' . $removed_staff_calendar_id );
					}

					// Remove event from removed staff.
					$this->remove_from_gcal( $appointment_id, $removed_staff_id, $removed_staff_calendar_id, false );
				}
			}

			// Reset to global calendar sync.
			$site_calendar_id  = get_option( 'wc_appointments_gcal_calendar_id' );
			$this->set_calendar_id( $site_calendar_id );
			$this->set_user_id( 0 );

			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				#wc_add_appointment_log( $this->id, 'Sync reset to global' );
			}
		}

		// Get staff that was added.
		#$from = [1, 2, 3, 4, 5];
		#$to = [4, 5, 6, 7, 8];
		$added_staff_ids = array_diff( $to, $from );

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			#wc_add_appointment_log( $this->id, 'Added staff IDs :' . var_export( $added_staff_ids, true ) );
		}
		if ( [] !== $added_staff_ids ) {
			foreach ( $added_staff_ids as $added_staff_id ) {
				$calendar_id             = get_user_meta( $added_staff_id, 'wc_appointments_gcal_calendar_id', true );
				$added_staff_calendar_id = $calendar_id ?: '';

				// Staff must have calendar ID set.
				if ( $added_staff_calendar_id ) {
					// Switch calendar to staff for new sync.
					$this->set_calendar_id( $added_staff_calendar_id );
					$this->set_user_id( $added_staff_id ); #reset to staff calendar sync.

					// Debug.
					if ( 'yes' === $this->get_debug() ) {
						#wc_add_appointment_log( $this->id, 'Adding appointment #' . $appointment_id . ' for staff #' . $added_staff_id . 'to Google Calendar: ' . $added_staff_calendar_id );
					}

					// Sync event to added staff.
					$this->sync_to_gcal( $appointment_id, $added_staff_id, $added_staff_calendar_id, false ); #false - do not sync other staff.
				}
			}

			// Reset to global calendar sync.
			$site_calendar_id  = get_option( 'wc_appointments_gcal_calendar_id' );
			$this->set_calendar_id( $site_calendar_id );
			$this->set_user_id( 0 );

			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				#wc_add_appointment_log( $this->id, 'Sync reset to global' );
			}
		}

		$this->staff_syncing = false;
	}

	/**
	 * Sync an event resource with Google Calendar.
	 * https://developers.google.com/google-apps/calendar/v3/reference/events
	 *
	 * @param   int            $appointment_id Appointment ID
	 * @param   array          $params Set of parameters to be passed to the http request
	 * @param   array          $data Optional set of data for writeable syncs
	 * @since                  3.5.6
	 * @version                3.5.6
	 * @return  object|boolean Parsed JSON data from the http request or false if error
	 */
	public function sync_event_resource( $appointment_id = -1, array $params = [], array $resource_params = [], $data = [] ) {
		if ( 0 > $appointment_id ) {
			return;
		}

		$appointment = get_wc_appointment( $appointment_id );
		$event_id    = $resource_params['event_id'];
		$staff_id    = $resource_params['staff_id'];
		$calendar_id = $resource_params['calendar_id'];
		$api_url     = $this->calendars_uri . $calendar_id . '/events' . ( ( $event_id ) ? '/' . $event_id : '' );
		$json_data   = false;

		if ( isset( $params['method'] ) && 'GET' !== $params['method'] ) {
			$params['body'] = wp_json_encode( apply_filters( 'woocommerce_appointments_gcal_sync', $data, $appointment ) );
		}

		try {

			$response  = $this->make_gcal_request( $api_url, $params, $staff_id );
			$json_data = json_decode( $response['body'], true );

			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, 'Synced appointment #' . $appointment->get_id() . ' with Google Calendar: ' . $calendar_id ); #debug
			}

		} catch ( Exception $e ) {
			$json_data = false;

			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, 'Error while getting data for ' . $api_url . ': ' . print_r( $response, true ) ); #debug
			}
		}

		return $json_data;

	}

	/**
      * Sync Appointment to GCal
      *
      * @param  int $appointment_id Appointment ID
      */
     public function sync_to_gcal( $appointment_id, $appointment_staff_id = false, $staff_calendar_id = false, $staff_sync = false ): void {
		if ( 'wc_appointment' !== get_post_type( $appointment_id ) ) {
			return;
		}

		// Compute target calendar for this sync operation.
		$target_calendar_id = ( $appointment_staff_id && $staff_calendar_id ? $staff_calendar_id : $this->get_calendar_id() );

		// During incoming sync, prevent syncing back to the SAME calendar the event came from,
		// but allow cross-calendar sync to OTHER calendars.
		if ( $this->syncing && '' !== $this->incoming_sync_calendar_id && $target_calendar_id === $this->incoming_sync_calendar_id ) {
			// Event is coming from this calendar, don't send it back to the same calendar.
			return;
		}

        /**
         * woocommerce_appointments_sync_to_gcal_start hook
         */
        do_action( 'woocommerce_appointments_sync_to_gcal_start', $appointment_id, $appointment_staff_id );

        // Get appointment object.
        $appointment = get_wc_appointment( $appointment_id );

        // Staff calendar should be different from main calendar.
        if ( $appointment_staff_id ) {
            $staff_event_ids = $appointment->get_google_calendar_staff_event_ids();
            $event_id        = $staff_event_ids[ $appointment_staff_id ] ?? '';
        } else {
            $event_id = $appointment->get_google_calendar_event_id();
        }

        // Compute active calendar and acquire a short-lived appointment-scoped lock to avoid duplicate creates.
        $active_calendar_id = ( $appointment_staff_id && $staff_calendar_id ? $staff_calendar_id : $this->get_calendar_id() );
        $lock_scope         = ( $appointment_staff_id ? ( 'staff_' . (int) $appointment_staff_id ) : 'site' ) . '|' . $active_calendar_id;

        if ( ! $this->acquire_event_lock( $appointment_id, $lock_scope ) ) {
            // Another sync in-flight for this appointment/cal.
            return;
        }

        try {
            // Preflight search: if no local event id, try to find an existing remote event via extended properties.
            if ( ! $event_id && $active_calendar_id ) {
                $search_url = $this->calendars_uri . $active_calendar_id . '/events'
                    . '?singleEvents=true&maxResults=2&orderBy=startTime'
                    . '&privateExtendedProperty=' . rawurlencode( 'appointment_id=' . $appointment_id )
                    . '&privateExtendedProperty=' . rawurlencode( 'site_url=' . home_url() );
                if ( $appointment_staff_id ) {
                    $search_url .= '&privateExtendedProperty=' . rawurlencode( 'staff_id=' . $appointment_staff_id );
                }

                $resp = $this->make_gcal_request( $search_url, [ 'method' => 'GET' ], $appointment_staff_id ?: '' );
                if ( $resp && ! is_wp_error( $resp ) ) {
                    $decoded = json_decode( $resp['body'], true );
                    if ( is_array( $decoded ) && ! empty( $decoded['items'] ) && isset( $decoded['items'][0]['id'] ) ) {
                        $event_id = (string) $decoded['items'][0]['id'];
                        // Persist found event id to appointment meta to ensure updates go via PUT.
                        if ( $appointment_staff_id ) {
                            $appointment->set_google_calendar_staff_event_ids( [ $appointment_staff_id => $event_id ] );
                        } else {
                            $appointment->set_google_calendar_event_id( wc_clean( $event_id ) );
                        }
                        $appointment->save();
                    }
                }
            }

            $product    = $appointment->get_product();
            $order      = $appointment->get_order();
            $customer   = $appointment->get_customer();
            $timezone   = wc_timezone_string();

		/* translators: %d: Appointment ID */
		$summary     = sprintf( __( 'Appointment #%d', 'woocommerce-appointments' ), $appointment_id ) . ( $product ? ' - ' . html_entity_decode( $product->get_title() ) : '' );
		$description = '';

		// Add customer name.
		if ( $customer ) {
			$description .= sprintf( '%s: %s', __( 'Customer', 'woocommerce-appointments' ), $customer->full_name ) . PHP_EOL;
		}

		// Product name.
		if ( is_object( $product ) ) {
			$description .= sprintf( '%s: %s', __( 'Product', 'woocommerce-appointments' ), $product->get_title() ) . PHP_EOL;
		}

		// Appointment data (same as before)...
		$appointment_data = [
			__( 'Appointment ID', 'woocommerce-appointments' ) => $appointment_id,
			__( 'When', 'woocommerce-appointments' )      => $appointment->get_start_date(),
			__( 'Duration', 'woocommerce-appointments' )  => $appointment->get_duration(),
			__( 'Providers', 'woocommerce-appointments' ) => $appointment->get_staff_members( true ),
		];
		$padding_duration = $product->get_padding_duration();
		if ( $padding_duration && in_array( $product->get_duration_unit(), [ 'hour', 'minute', 'day' ], true ) ) {
			$appointment_data += [
				__( 'Padding Time', 'woocommerce-appointments' ) => WC_Appointment_Duration::format_minutes( $padding_duration, $product->get_duration_unit() ),
			];
		}
		foreach ( $appointment_data as $key => $value ) {
			if ( empty( $value ) ) {
				continue;
			}
			$description .= sprintf(
			    '%1$s: %2$s',
			    rawurldecode( html_entity_decode( $key ) ),
			    rawurldecode( html_entity_decode( $value ) ),
			) . PHP_EOL;
		}

		// Addons and other order items (unchanged)...
		if ( is_a( $order, 'WC_Order' ) ) {
			foreach ( $order->get_items() as $order_item_id => $order_item ) {
				if ( WC_Appointment_Data_Store::get_appointment_order_item_id( $appointment_id ) !== $order_item_id ) {
					continue;
				}
				foreach ( $order_item->get_all_formatted_meta_data() as $meta ) {
					$description .= apply_filters(
					    'woocommerce_appointments_gcal_sync_order_meta',
					    sprintf( '%s: %s', rawurldecode( html_entity_decode( $meta->key ) ), rawurldecode( html_entity_decode( $meta->value ) ) ),
					    $meta,
					    $order,
					) . PHP_EOL;
				}
			}
		}

            // Resource params.
            $resource_params = [
                'event_id'    => $event_id,
                'staff_id'    => $appointment_staff_id,
                'calendar_id' => $active_calendar_id,
            ];

		// Build event payload.
		$data = [
			'summary'     => wp_kses_post( $summary ),
			'description' => wp_kses_post( $description ),
			'extendedProperties' => [
				'private' => [
					'appointment_id' => (string) $appointment_id,
					'site_url'       => home_url(),
					'staff_id'       => $appointment_staff_id ? (string) $appointment_staff_id : '',
					'order_id'       => is_a( $order, 'WC_Order' ) ? (string) $order->get_id() : '',
				],
			],
		];

		// Start/end times with explicit timeZone or all-day dates.
		if ( $appointment->is_all_day() ) {
			$data['end'] = [ 'date' => date( 'Y-m-d', $appointment->get_end() ) ];
			$data['start'] = [ 'date' => date( 'Y-m-d', $appointment->get_start() ) ];
		} else {
			$data['end'] = [ 'dateTime' => date( 'Y-m-d\TH:i:s', $appointment->get_end() ), 'timeZone' => $timezone ];
			$data['start'] = [ 'dateTime' => date( 'Y-m-d\TH:i:s', $appointment->get_start() ), 'timeZone' => $timezone ];
		}

		// OPTIONAL: Visibility (private|public|default) — off by default (no change unless filtered).
		$visibility = apply_filters( 'wc_appointments_gcal_event_visibility', null, $appointment, $resource_params, $this );
		if ( null !== $visibility ) {
			$data['visibility'] = (string) $visibility;
		}

		// OPTIONAL: colorId — off by default.
		$color_id = apply_filters( 'wc_appointments_gcal_event_color_id', null, $appointment, $resource_params, $this );
		if ( null !== $color_id ) {
			$data['colorId'] = (string) $color_id;
		}

		// OPTIONAL: attendees — off by default.
		$attendees = apply_filters( 'wc_appointments_gcal_event_attendees', [], $appointment, $resource_params, $this );
		if ( ! empty( $attendees ) && is_array( $attendees ) ) {
			$data['attendees'] = array_values( array_filter( $attendees, 'is_array' ) );
		}

		// OPTIONAL: reminders — off by default.
            $reminders = apply_filters( 'wc_appointments_gcal_event_reminders', null, $appointment, $resource_params, $this );
            if ( is_array( $reminders ) ) {
                $data['reminders'] = $reminders; // Expecting Google Calendar reminders structure.
            }

		// Conflict policy (prefer_site | prefer_calendar). Default to prefer_site (no change in behavior).
            $conflict_policy = apply_filters( 'wc_appointments_gcal_conflict_policy', 'prefer_site', $appointment, $resource_params, $this );

		// If updating an existing event, load existing to support conflict checks and no-op avoidance.
            $existing = [];
            if ( $event_id ) {
                $response_data = $this->sync_event_resource(
                    $appointment_id,
                    [
                        'method'      => 'GET',
                        'querystring' => [
                            'fields' => 'summary,description,start,end,updated,etag',
                        ],
                    ],
                    $resource_params,
                );
                $existing = is_array( $response_data ) ? $response_data : [];

                if ( 'prefer_calendar' === $conflict_policy && isset( $existing['updated'] ) && $existing['updated'] ) {
                    $remote_updated_ts = strtotime( $existing['updated'] );
                    if ( $remote_updated_ts && 60 > ( time() - $remote_updated_ts ) ) {
                        // Skip overwrite if remote changed very recently.
                        return;
                    }
                }

                // No-op avoidance (skip PUT if core fields unchanged).
                $extract_time = function( $edge ): string {
                    if ( ! is_array( $edge ) ) {
                        return '';
                    }
                    if ( isset( $edge['dateTime'] ) ) {
                        return (string) $edge['dateTime'];
                    }
                    if ( isset( $edge['date'] ) ) {
                        return (string) $edge['date'];
                    }
                    return '';
                };
                $existing_fingerprint = [
                    'summary'     => (string) ( $existing['summary'] ?? '' ),
                    'description' => (string) ( $existing['description'] ?? '' ),
                    'start'       => $extract_time( $existing['start'] ?? [] ),
                    'end'         => $extract_time( $existing['end'] ?? [] ),
                ];
                $new_fingerprint = [
                    'summary'     => (string) $data['summary'],
                    'description' => (string) $data['description'],
                    'start'       => $extract_time( $data['start'] ),
                    'end'         => $extract_time( $data['end'] ),
                ];
                $should_skip = apply_filters(
                    'wc_appointments_gcal_skip_noop_update',
                    $existing_fingerprint === $new_fingerprint,
                    $existing_fingerprint,
                    $new_fingerprint,
                    $appointment,
                    $this,
                );
                if ( $should_skip ) {
                    return;
                }
            }

            $response_data = $this->sync_event_resource(
                $appointment_id,
                [
                    'method' => $event_id ? 'PUT' : 'POST',
                ],
                $resource_params,
                $data,
            );

            // Save event IDs back (unchanged from before)...
            if ( isset( $response_data['id'] ) ) {
                if ( $appointment_staff_id ) {
                    $appointment->set_google_calendar_staff_event_ids( [ $appointment_staff_id => $response_data['id'] ] );
                } else {
                    $appointment->set_google_calendar_event_id( wc_clean( $response_data['id'] ) );
                }
            }

            $appointment->skip_status_transition_events();
            $appointment->save();

            $staff_ids = $appointment->get_staff_ids();
            if ( $staff_ids && ! $appointment_staff_id && $staff_sync ) {
                foreach ( $staff_ids as $staff_id ) {
                    $calendar_id       = get_user_meta( $staff_id, 'wc_appointments_gcal_calendar_id', true );
                    $staff_calendar_id = $calendar_id ?: '';
                    if ( $staff_calendar_id ) {
                        $this->sync_to_gcal( $appointment_id, $staff_id, $staff_calendar_id, false );
                    }
                }
            }
        } finally {
            // Always release appointment-scoped lock.
            $this->release_event_lock( $appointment_id, $lock_scope );
        }
    }

	/**
	 * Backoff-enabled HTTP request wrapper (GET/POST/PUT/PATCH/DELETE).
	 * Retries on transient conditions (403 rate limits, 429, 5xx).
	 *
	 * @param string $url
	 * @param array  $args wp_remote_* args
	 * @return array|\WP_Error
	 */
	protected function http_request_with_backoff( $url, array $args ) {
		$max_retries = (int) apply_filters( 'wc_appointments_gcal_http_max_retries', 3, $this );
		$delay_base  = (int) apply_filters( 'wc_appointments_gcal_http_backoff_base_ms', 300, $this );

		for ( $attempt = 0; $attempt <= $max_retries; $attempt++ ) {
			$response = wp_safe_remote_request( $url, $args );

			if ( is_wp_error( $response ) ) {
				// Retry WP_Error only on transient networking issues.
				if ( $attempt < $max_retries ) {
					usleep( ( $delay_base * ( 2 ** $attempt ) + random_int( 0, 150 ) ) * 1000 );
					continue;
				}
				return $response;
			}

			$code = (int) wp_remote_retrieve_response_code( $response );

			// Retry on transient/quota errors.
			if ((in_array( $code, [ 429, 500, 502, 503 ], true ) || $this->is_rate_limit_error($response)) && $attempt < $max_retries) {
                usleep( ( $delay_base * ( 2 ** $attempt ) + random_int( 0, 150 ) ) * 1000 );
                continue;
            }

			return $response;
		}

		return $response;
	}

	/**
	 * Detect Google Calendar rate limit responses from body.
	 *
	 * @param array $response
	 * @return bool
	 */
	protected function is_rate_limit_error( $response ) {
		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 403 !== $code ) {
			return false;
		}
		$body = wp_remote_retrieve_body( $response );
		if ( ! $body ) {
			return false;
		}
		$data = json_decode( $body, true );
		if ( ! is_array( $data ) ) {
			return false;
		}
		// Match common Google error reasons.
		$reason = $data['error']['errors'][0]['reason'] ?? '';
		return in_array( $reason, [ 'rateLimitExceeded', 'userRateLimitExceeded', 'quotaExceeded' ], true );
	}

	/**
     * Remove/cancel the appointment in GCal
     *
     * @param  int $appointment_id Appointment ID
     * @param  int $appointment_staff_id Staff ID
     * @param  string $staff_calendar_id Staff synced calendar ID
     * @param  void $staff_sync Should we sync all staff?
     */
    public function remove_from_gcal( $appointment_id, $appointment_staff_id = false, $staff_calendar_id = false, $staff_sync = false ): void {
		// Compute target calendar for this removal operation.
		$target_calendar_id = $staff_calendar_id ?: $this->get_calendar_id();

		// During incoming sync, prevent removing from the SAME calendar the event came from,
		// but allow cross-calendar removal to OTHER calendars.
		if ( $this->syncing && '' !== $this->incoming_sync_calendar_id && $target_calendar_id === $this->incoming_sync_calendar_id ) {
			// Event is coming from this calendar, don't send removal back to the same calendar.
			return;
		}

		// Stop the removal with a filter.
		$stop_the_removal = apply_filters(
		    'woocommerce_appointments_gcal_remove_from_gcal',
		    false,
		    $appointment_id,
		    $appointment_staff_id,
		    $staff_calendar_id,
		    $this,
		);
		if ( $stop_the_removal ) {
			return;
		}

		// Check if appointment exists.
		$appointment = get_wc_appointment( $appointment_id );
		if ( ! $appointment ) {
			return;
		}

		// Staff calendar should be different from main calendar.
		if ( $appointment_staff_id ) {
			$staff_event_ids = $appointment->get_google_calendar_staff_event_ids();
			$event_id       = $staff_event_ids[ $appointment_staff_id ] ?? '';
		} else {
			$event_id = $appointment->get_google_calendar_event_id();
		}

		// Stop removal, when no event is synced.
		if ( ! $event_id ) {
			return;
		}

		// Check if Authorized.
		$access_token = $this->get_access_token( '', $appointment_staff_id );
		if ( ! $access_token ) {
			return;
		}

		// Calendar ID.
		$calendar_id = $staff_calendar_id ?: $this->get_calendar_id();

		// Stop here if calendar is not set.
		if ( ! $calendar_id ) {
			return;
		}

		// Remove event.
		if ( $event_id ) {
			$api_url = $this->calendars_uri . $calendar_id . '/events/' . $event_id;

			// Connection params.
			$params = [
				'method'    => 'DELETE',
				'sslverify' => true,
				'timeout'   => 60,
				'headers'   => [
					'Content-Type'  => 'application/json',
					'Authorization' => 'Bearer ' . $access_token,
				],
			];

			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				#wc_add_appointment_log( $this->id, 'Removing event #' . $event_id . ' from Google Calendar: ' . $calendar_id );
			}

			$response = $this->http_request_with_backoff( $api_url, $params );

			if ( ! is_wp_error( $response ) && isset( $response['response']['code'] ) && 204 == $response['response']['code'] ) {

				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					#wc_add_appointment_log( $this->id, 'Event #' . $event_id . ' removed successfully!' );
				}

			// Debug.
			} elseif ( 'yes' === $this->get_debug() ) {
				$body = $response['body'] ?? '';
				wc_add_appointment_log( $this->id, 'Error while removing event #' . $event_id . ': from Google Calendar: ' . $calendar_id . ' : ' . var_export( $body, true ) );
			}

			// Get staff IDs.
			$staff_ids = $appointment->get_staff_ids();

			// Sync for each staff.
			// Only when $appointment_staff_id is false,
			// so it does not go into inifinite loop.
			if ( $staff_ids && ! $appointment_staff_id && $staff_sync ) {
				foreach ( $staff_ids as $staff_id ) {
					$calendar_id      = get_user_meta( $staff_id, 'wc_appointments_gcal_calendar_id', true );
					$staff_calendar_id = $calendar_id ?: '';
					// Staff must have calendar ID set.
					if ( $staff_calendar_id ) {
						$this->remove_from_gcal( $appointment_id, $staff_id, $staff_calendar_id, true );
					}
				}
			}
		}
	}

	public function get_synced_staff_ids(): ?array {
		// Get all users set as staff.
		$all_staff = get_users(
		    [
				'role'    => 'shop_staff',
				'orderby' => 'nicename',
				'order'   => 'asc',
				'fields'  => [ 'ID' ],
			],
		);

		if ( $all_staff ) {
			$synced_ids = [];
			foreach ( $all_staff as $provider ) {
				if ( ! isset( $provider->ID ) ) {
					continue;
				}
				$two_way     = get_user_meta( $provider->ID, 'wc_appointments_gcal_twoway', true );
				$calendar_id = get_user_meta( $provider->ID, 'wc_appointments_gcal_calendar_id', true );

				if ( 'two_way' === $two_way && $calendar_id ) {
					$synced_ids[] = absint( $provider->ID );
				}
			}

			// Array of staff with sync enabled.
			if ( [] !== $synced_ids ) {
				return $synced_ids;
			}
		}

		return null;
	}

	/**
	 * Run the schedule and sync from GCal.
	 * Wired to use incremental (syncToken) or forward-only window fetch, then delegate event processing.
	 * Falls back to the legacy body when the feature flag is disabled.
	 */
	public function sync_from_gcal( $user_id = '' ): void {
		// Get all staff with sync enabled.
		$synced_staff = $this->get_synced_staff_ids();
		if ( $synced_staff && ! $user_id ) {
			foreach ( $synced_staff as $synced_staff_id ) {
				$this->sync_from_gcal( $synced_staff_id );
			}
		}

		// Set user id, calendar and 2-way sync.
		if ( $user_id ) {
			$this->set_user_id( $user_id );
		} else {
			$this->set_user_id( 0 ); // reset to global calendar sync.
		}

		// Two way sync not enabled.
		if ( ! $this->is_twoway_enabled() ) {
			return;
		}

		// Check if Authorized and if calendar is set.
		$access_token    = $this->get_access_token();
		$is_calendar_set = $this->is_calendar_set();
		if ( ! $access_token || ! $is_calendar_set ) {
			return;
		}

		// New path: centralized fetch with incremental sync token or forward-only window.
		$use_new_fetch = (bool) apply_filters( 'wc_appointments_gcal_use_new_fetch', true, $this );

		if ( $use_new_fetch ) {
			// Build forward-only window (now -> availability cache horizon).
			$window = $this->get_forward_sync_window();

			// Read stored incremental token for this context (per-staff or global).
			$current_user = $this->get_user_id() ? (int) $this->get_user_id() : '';
			$sync_token   = $this->get_sync_token( $current_user );

			// Fetch events (delta if token exists, otherwise bounded forward window).
			$fetched = $this->fetch_events_delta_or_window( $access_token, $window, $sync_token );
			$events  = is_array( $fetched['events'] ?? null ) ? $fetched['events'] : [];
			$next    = (string) ( $fetched['nextSyncToken'] ?? '' );

			// If previous token existed but we got nothing back, retry once with a clean window.
			if ( [] === $events && ('' !== $sync_token && '0' !== $sync_token) && ('' === $next || '0' === $next) ) {
				$this->log_debug( 'events_fetch_retry', 'No events returned with syncToken; retrying with forward-only window.' );
				$fetched = $this->fetch_events_delta_or_window( $access_token, $window, '' );
				$events  = is_array( $fetched['events'] ?? null ) ? $fetched['events'] : [];
				$next    = (string) ( $fetched['nextSyncToken'] ?? '' );
			}

			// Persist next incremental token for subsequent runs.
			if ( '' !== $next ) {
				$this->set_sync_token( $next, $current_user );
			}

			// Hand off to existing ingestion by emulating the response.
			$emulated_response = [
				'response' => [
					'code'    => 200,
					'message' => 'OK',
				],
				'body'     => wp_json_encode(
				    [
						'items'         => $events,
						'nextSyncToken' => $next,
					],
				),
			];

			$this->gcal_fetch_events( $emulated_response );

			// Update last-sync diagnostics.
			/* translators: %d event count */
			$this->mark_last_sync_status( true, sprintf( __( 'Fetched %d event(s).', 'woocommerce-appointments' ), count( $events ) ) );
			return;
		}

		// Legacy path (kept for safety and backward-compatibility).
		$params = [
			'method'    => 'GET',
			'sslverify' => true,
			'timeout'   => 60,
			'headers'   => [
				'Content-Type'  => 'application/json',
				'Authorization' => 'Bearer ' . $access_token,
			],
		];

		// Don't sync events older than now.
		$timeMin = new DateTime();
		$timeMin->setTimezone( new DateTimeZone( wc_timezone_string() ) );
		$timeMin = $timeMin->format( 'Y-m-d\TH:i:sP' ); #\DateTime::RFC3339
		$timeMin = rawurlencode( $timeMin );

		// Don't sync events more than 1 year in future.
		$timeMax = new DateTime();
		$timeMax->setTimezone( new DateTimeZone( wc_timezone_string() ) );
		$timeMax->modify( '+1 year' );
		$timeMax = $timeMax->format( 'Y-m-d\TH:i:sP' ); #\DateTime::RFC3339
		$timeMax = rawurlencode( $timeMax );

		// Time zone.
		$timeZone = wc_timezone_string();

		// Get sync token.
		$sync_token = $this->get_sync_token();

		// maxResults 1000, 250 by default.
		if ( $this->get_sync_token() !== '' && $this->get_sync_token() !== '0' ) { #updated events only
			$response = wp_safe_remote_post( $this->calendars_uri . $this->get_calendar_id() . '/events' . "?singleEvents=false&showDeleted=true&syncToken=$sync_token&timeZone=$timeZone", $params );
		} else { #full sync
			$response = wp_safe_remote_post( $this->calendars_uri . $this->get_calendar_id() . '/events' . "?singleEvents=false&showDeleted=true&maxResults=1000&timeMin=$timeMin&timeMax=$timeMax&timeZone=$timeZone", $params );
		}

		if ( ! is_wp_error( $response ) && 410 == $response['response']['code'] ) {
			$this->set_sync_token( 0 );
			$response = wp_safe_remote_post( $this->calendars_uri . $this->get_calendar_id() . '/events' . "?singleEvents=false&showDeleted=true&maxResults=1000&timeMin=$timeMin&timeMax=$timeMax&timeZone=$timeZone", $params );
		}

		if ( is_wp_error( $response ) ) {
			// WP_Error may wrap a valid Google Calendar JSON response in its error message.
			// Check if the error message is actually valid Google JSON (contains 'items' array).
			$error_msg = $response->get_error_message();

			// Try to decode as JSON first
			$decoded = json_decode( $error_msg, true );

			if ( is_array( $decoded ) && array_key_exists( 'items', $decoded ) ) {
				// Valid Google response wrapped in WP_Error - process it normally.
				if ( 'yes' === $this->get_debug() ) {
					wc_add_appointment_log( $this->id, 'Received valid Google Calendar response in WP_Error, processing events.' );
				}

				// Create a synthetic successful response to pass to gcal_fetch_events().
				$synthetic_response = [
					'response' => [
						'code'    => 200,
						'message' => 'OK',
					],
					'body' => $error_msg,
				];

				$this->gcal_fetch_events( $synthetic_response );
				$this->mark_last_sync_status( true, __( 'Processed events from error-wrapped response.', 'woocommerce-appointments' ) );
			} else {
				// Genuine error - log it.
				if ( 'yes' === $this->get_debug() ) {
					wc_add_appointment_log( $this->id, 'Error while performing sync from Google Calendar: ' . $this->get_calendar_id() . ': ' . $error_msg );
				}
				$this->mark_last_error( wp_strip_all_tags( $error_msg ) );
			}
			return;
		}

		$this->gcal_fetch_events( $response );

		// Update diagnostics on legacy path as well.
		$this->mark_last_sync_status( true, __( 'Legacy fetch completed.', 'woocommerce-appointments' ) );
	}

	/**
	 * Record a successful/failed last-sync status (per-user or global).
	 *
	 * @param bool   $ok
	 * @param string $message
	 */
	protected function mark_last_sync_status( $ok, $message = '' ): void {
		$payload = [
			'ts'  => (int) current_time( 'timestamp' ),
			'ok'  => (bool) $ok,
			'msg' => (string) $message,
		];
		$uid = $this->get_user_id() ? (int) $this->get_user_id() : 0;
		if ( 0 !== $uid ) {
			update_user_meta( $uid, 'wc_appointments_gcal_last_sync', $payload );
		} else {
			update_option( 'wc_appointments_gcal_last_sync', $payload, false );
		}
	}

	/**
	 * Record last error text/time for diagnostics (per-user or global).
	 *
	 * @param string $error_text
	 */
	protected function mark_last_error( $error_text ): void {
		$uid = $this->get_user_id() ? (int) $this->get_user_id() : 0;
		$txt = trim( (string) $error_text );
		if ( 0 !== $uid ) {
			update_user_meta( $uid, 'wc_appointments_gcal_last_error', $txt );
		} else {
			update_option( 'wc_appointments_gcal_last_error', $txt, false );
		}
	}

	/**
     * Fetch the events and generate gcal availability.
     *
     * @param  array  $global_availability   Availability rules.
     */
    public function gcal_fetch_events( $response ): void {
		// Stop here if no $response.
		if ( ! $response ) {
			return;
		}

		$this->syncing = true;

		// Track the source calendar to allow cross-calendar sync while preventing loop-back.
		$this->incoming_sync_calendar_id = (string) $this->get_calendar_id();

		// Get gcals availability rules.
		$gcal_availability_rules = $this->gcal_availability_rules( $response );

		// Make sure $gcal_availability_rules is array or object so count() works.
		$gcal_availability_rules = is_array( $gcal_availability_rules ) || is_object( $gcal_availability_rules ) ? $gcal_availability_rules : [];

		// Last synced variables.
		// 0: current time in timestamp.
		// 1: number of events synced.
		$last_synced[] = absint( current_time( 'timestamp' ) );
		$last_synced[] = absint( count( $gcal_availability_rules ) );

		// Save gcal availability.
		if ( $this->get_user_id() ) {
			update_user_meta( $this->get_user_id(), 'wc_appointments_gcal_availability_last_synced', $last_synced );
		} else {
			update_option( 'wc_appointments_gcal_availability_last_synced', $last_synced );
		}

		$this->syncing = false;
		$this->incoming_sync_calendar_id = '';
	}

	/**
	 * Generate availability rules from GCal.
	 *
	 * @param array $response Response from Google Calendar API.
	 *
	 * @return array|null Array of event IDs processed or null on error.
	 */
	public function gcal_availability_rules( array $response ): ?array {
		global $wpdb;

		if ( $this->staff_syncing ) {
			// staff is being added/removed, don't touch the appointments.
			return null;
		}

		// Response error.
		if ( is_wp_error( $response ) || 200 !== $response['response']['code'] || 'OK' !== strtoupper( $response['response']['message'] ) ) {

			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, 'Error while performing sync from Google Calendar: ' . $this->get_calendar_id() . ': ' . var_export( $response['body'], true ) );
			}
			return null;
		}

		// Hook: woocommerce_appointments_sync_from_gcal_start
		do_action( 'woocommerce_appointments_sync_from_gcal_start', $response );

		// Get site's time zone.
		$wp_appointments_timezone = wc_timezone_string();

		// Get time zone offset.
		wc_appointment_get_timezone_offset( $wp_appointments_timezone );

		// Get response data.
		$response_data = json_decode( $response['body'], true );

		// No events.
		if ( empty( $response_data['items'] ) || ! is_array( $response_data['items'] ) ) {
			return null;
		}

		// Set next sync token.
		$sync_token = $response_data['nextSyncToken'] ?? '';
		if ( $sync_token ) {
			$this->set_sync_token( $sync_token );
		}

		// Set event ids for counting later.
		$gcal_count = [];

		// Set event eid if it exists later.
		$appointment_eid = 0;

		/**
		 * Availability Data store instance.
		 *
		 * @var WC_Appointments_Availability_Data_Store $availability_data_store
		 */
		$availability_data_store = WC_Data_Store::load( WC_Appointments_Availability::DATA_STORE );

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			if ( $this->get_user_id() ) {
				#wc_add_appointment_log( $this->id, 'List events from Google for staff #' . $this->get_user_id() . ':' . var_export( $response_data, true ) );
			} else {
				#wc_add_appointment_log( $this->id, 'List events from Google: ' . var_export( $response_data, true ) );
			}
		}

		// Assemble events
		foreach ( $response_data['items'] as $event ) {
			// Hook: woocommerce_appointments_sync_from_gcal_start
			do_action( 'woocommerce_appointments_sync_from_gcal_event_start', $event );

			// Debug: Log every event we process.
			if ( 'yes' === $this->get_debug() ) {
				$event_status = strtoupper( $event['status'] ?? 'UNKNOWN' );
				$event_type = 'regular event';
				if ( isset( $event['recurringEventId'] ) ) {
					$event_type = 'recurring instance (parent: ' . $event['recurringEventId'] . ')';
				} elseif ( isset( $event['recurrence'] ) ) {
					$event_type = 'recurring parent';
				}
				wc_add_appointment_log( $this->id, sprintf( 'Processing event #%s [%s] - Type: %s', $event['id'], $event_status, $event_type ) );
				#wc_add_appointment_log( $this->id, var_export( $event, true ) );
			}

			// Check if all day event.
			// value = DATE for all day, otherwise time included.
			$all_day = isset( $event['start']['date'] ) && isset( $event['end']['date'] );

			// Initialize date variables - some cancelled events may not have start/end.
			$dtstart = null;
			$dtend   = null;

			// Only parse dates if the event has start/end properties.
			if ( isset( $event['start'] ) && isset( $event['end'] ) ) {
				if ( $all_day ) {
					// Get Start and end date information
					$dtstart = new WC_DateTime( $event['start']['date'] );
					$dtend   = new WC_DateTime( $event['end']['date'] );
					$dtend->modify( '-1 second' ); // reduce 1 sec from end date.
				} elseif ( isset( $event['start']['dateTime'] ) && isset( $event['end']['dateTime'] ) ) {
					// Get Start and end datetime information
					$dtstart = new WC_DateTime( $event['start']['dateTime'] );
					// $dtstart->setTimezone( new DateTimeZone( $wp_appointments_timezone ) );
					$dtend = new WC_DateTime( $event['end']['dateTime'] );
					// $dtend->setTimezone( new DateTimeZone( $wp_appointments_timezone ) );
				}
			}

			// For cancelled instances of recurring events, look up by parent event ID.
			$lookup_event_id = $event['id'];
			$is_cancelled_instance = isset( $event['recurringEventId'] ) && ! empty( $event['recurringEventId'] ) && 'CANCELLED' === strtoupper( $event['status'] );

			if ( $is_cancelled_instance ) {
				$lookup_event_id = $event['recurringEventId'];

				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					wc_add_appointment_log( $this->id, 'Detected cancelled instance of recurring event. Instance ID: ' . $event['id'] . ', Parent ID: ' . $event['recurringEventId'] );
				}
			}

			// Load all synced availabilities.
			$availabilities = $availability_data_store->get_all(
			    [
					[
						'key'     => 'event_id',
						'compare' => '=',
						'value'   => $lookup_event_id,
					],
				],
			);

			// Debug: Log availability lookup result for ALL events.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, sprintf( 'Looked up availabilities for event_id=%s: Found %d rule(s)', $lookup_event_id, count( $availabilities ) ) );
			}

			// Handle cancelled instances BEFORE checking for empty availabilities.
			if ( $is_cancelled_instance && ! empty( $availabilities ) ) {
				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					wc_add_appointment_log( $this->id, 'Processing cancelled instance #' . $event['id'] . ' for parent event #' . $event['recurringEventId'] . '. Found ' . count( $availabilities ) . ' availability rule(s).' );
				}

				// Extract the exception date from the cancelled instance.
				$exception_date = $this->get_exception_date_from_event( $event );

				if ( ! $exception_date ) {
					// Debug.
					if ( 'yes' === $this->get_debug() ) {
						wc_add_appointment_log( $this->id, 'ERROR: Could not extract exception date from cancelled instance #' . $event['id'] );
					}
					continue;
				}

				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					wc_add_appointment_log( $this->id, 'Extracted exception date: ' . $exception_date . ' from cancelled instance #' . $event['id'] );
				}

				foreach ( $availabilities as $availability ) {
					// Get old rrule for comparison.
					$old_rrule = $availability->get_rrule();

					// Add exception date to the availability rule.
					$this->add_exception_to_availability( $availability, $exception_date );

					// Get new rrule after update.
					$new_rrule = $availability->get_rrule();

					$availability->save();

					// Debug.
					if ( 'yes' === $this->get_debug() ) {
						wc_add_appointment_log( $this->id, 'Updated availability rule #' . $availability->get_id() . ' with exception date ' . $exception_date );
						if ( $old_rrule !== $new_rrule ) {
							wc_add_appointment_log( $this->id, 'RRULE changed successfully. New RRULE: ' . substr( $new_rrule, 0, 300 ) );
						} else {
							wc_add_appointment_log( $this->id, 'WARNING: RRULE unchanged - exception may already exist' );
						}
					}
				}
				// Skip to next event after handling cancelled instance.
				continue;
			}

			// Handle MODIFIED instances of recurring events (has recurringEventId but NOT cancelled).
			// When someone changes the time for one occurrence, Google creates a modified instance
			// with different start/end times but still references the parent via recurringEventId.
			// We need to: 1) Add EXDATE to parent's rule for original time, 2) Create new rule for modified time.
			$is_modified_instance = isset( $event['recurringEventId'] ) && ! empty( $event['recurringEventId'] ) && 'CANCELLED' !== strtoupper( $event['status'] ?? '' );

			if ( $is_modified_instance ) {
				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					wc_add_appointment_log( $this->id, 'Processing MODIFIED instance #' . $event['id'] . ' for parent event #' . $event['recurringEventId'] );
				}

				// Step 1: Add EXDATE to parent's availability rule for the original time.
				// The parent availability rules were found using $lookup_event_id = $event['recurringEventId'].
				// But we need to look up by the PARENT event ID, not the instance ID.
				$parent_availabilities = $availability_data_store->get_all(
					[
						[
							'key'     => 'event_id',
							'compare' => '=',
							'value'   => $event['recurringEventId'],
						],
					],
				);

				if ( ! empty( $parent_availabilities ) ) {
					// Extract the original start time from the event to create EXDATE.
					$exception_date = $this->get_exception_date_from_event( $event );

					if ( $exception_date ) {
						foreach ( $parent_availabilities as $parent_availability ) {
							$this->add_exception_to_availability( $parent_availability, $exception_date );
							$parent_availability->save();

							// Debug.
							if ( 'yes' === $this->get_debug() ) {
								wc_add_appointment_log( $this->id, 'Added EXDATE ' . $exception_date . ' to parent availability rule #' . $parent_availability->get_id() . ' for modified instance' );
							}
						}
					}
				}

				// Step 2: Check if we already have an availability rule for this modified instance.
				$instance_availabilities = $availability_data_store->get_all(
					[
						[
							'key'     => 'event_id',
							'compare' => '=',
							'value'   => $event['id'],
						],
					],
				);

				if ( ! empty( $instance_availabilities ) ) {
					// Update existing availability rule for modified instance.
					foreach ( $instance_availabilities as $instance_availability ) {
						if ( ! $this->update_availability_from_event( $instance_availability, $event ) ) {
							// Debug: Event has invalid date format, skip.
							if ( 'yes' === $this->get_debug() ) {
								wc_add_appointment_log( $this->id, 'Skipping modified instance #' . $event['id'] . ' - invalid date format' );
							}
							continue;
						}
						// Store parent event ID reference for cascade deletion.
						$instance_availability->set_parent_event_id( $event['recurringEventId'] );
						$instance_availability->save();

						// Debug.
						if ( 'yes' === $this->get_debug() ) {
							wc_add_appointment_log( $this->id, 'Updated existing availability rule #' . $instance_availability->get_id() . ' for modified instance #' . $event['id'] );
						}
					}
				} else {
					// Create new availability rule for the modified instance's new time.
					$new_availability = get_wc_appointments_availability();
					if ( ! $this->update_availability_from_event( $new_availability, $event ) ) {
						// Debug: Event has invalid date format, skip.
						if ( 'yes' === $this->get_debug() ) {
							wc_add_appointment_log( $this->id, 'Skipping modified instance #' . $event['id'] . ' - invalid date format' );
						}
						continue;
					}
					// Store parent event ID reference for cascade deletion.
					$new_availability->set_parent_event_id( $event['recurringEventId'] );
					$new_availability->save();

					// Debug.
					if ( 'yes' === $this->get_debug() ) {
						wc_add_appointment_log( $this->id, 'Created new availability rule #' . $new_availability->get_id() . ' for modified instance #' . $event['id'] . ' (parent: ' . $event['recurringEventId'] . ')' );
					}
				}

				// Add to counter and continue to next event.
				$gcal_count[] = $event['id'];
				continue;
			}

			// No availabilities, check if an appointment matches the event.
			if ( empty( $availabilities ) ) {

				// Debug.
				if ( 'yes' === $this->get_debug() && $is_cancelled_instance ) {
					wc_add_appointment_log( $this->id, 'No availability rules found for parent event #' . $lookup_event_id . ' (cancelled instance #' . $event['id'] . ')' );
				}

				// Check if appointment ID is saved in extendedProperties (check both private and shared).
				// We set 'private' when syncing TO Google, so check that first.
				if ( isset( $event['extendedProperties']['private']['appointment_id'] ) ) {
					$appointment_eid = absint( $event['extendedProperties']['private']['appointment_id'] );
					$appointment_eid = is_string( get_post_status( $appointment_eid ) ) ? $appointment_eid : 0; // check if post exists.
				} elseif ( isset( $event['extendedProperties']['shared']['appointment_id'] ) ) {
					$appointment_eid = absint( $event['extendedProperties']['shared']['appointment_id'] );
					$appointment_eid = is_string( get_post_status( $appointment_eid ) ) ? $appointment_eid : 0; // check if post exists.
				} else {
					$appointment_eid = 0;
				}

				// Fallback: find appointment by stored Google Calendar event ID when extended properties are missing.
				if ( ! $appointment_eid && $lookup_event_id ) {
					$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_by(
						[
							'gcal_event_id' => $lookup_event_id,
							'limit'         => 1,
						],
					);
					if ( ! empty( $appointment_ids ) ) {
						$appointment_eid = absint( $appointment_ids[0] );
						$appointment_eid = is_string( get_post_status( $appointment_eid ) ) ? $appointment_eid : 0; // check if post exists.
					}
				}

				// Cancel appointment.
				if ( $appointment_eid && 0 !== $appointment_eid ) {
					// When event is deleted inside GCal set appointment status to cancelled and go to next event.
					if ( isset( $event['status'] ) && 'CANCELLED' === strtoupper( $event['status'] ) ) {
						// Get appointment object.
						$appointment = get_wc_appointment( $appointment_eid );

						// Don't proceed if ID is not of a valid appointment.
						if ( ! is_a( $appointment, 'WC_Appointment' ) ) {
							continue;
						}

						// Don't cancel trashed appointment.
						if ( 'trash' === $appointment->get_status() ) {
							continue;
						}

						// Don't cancel already cancelled appointment.
						if ( 'cancelled' === $appointment->get_status() ) {
							continue;
						}

						// Get staff IDs.
						$staff_ids = $appointment->get_staff_ids();

						// Testing.
						// wc_add_appointment_log( $this->id, 'Staff IDs: ' . var_export( $staff_ids, true ) );

						// Event coming from staff member.
						if ( $this->get_user_id() ) {
							// Remove staff that has cancelled event from appointment staff.
							$set_staff_ids = array_diff( $staff_ids, [ $this->get_user_id() ] );

							// Staff still present.
							if ( [] !== $set_staff_ids ) {
								// Save.
								$this->syncing = true;

								$appointment->set_staff_ids( $set_staff_ids );
								$appointment->save();

								$this->syncing = false;

								// Staff removed, but skipped cancellation.
								if ( 'yes' === $this->get_debug() ) {
									wc_add_appointment_log( $this->id, 'Successfully removed staff #' . $this->get_user_id() . ' from appointment #' . $appointment_eid . ' from Google Calendar event #' . $event['id'] );
								}

								// Skip cancellation.
								continue;

							// No staff present any more.
							} else {
								// Save.
								$this->syncing = true;

								$appointment->set_staff_ids( [] );
								$appointment->save();

								$this->syncing = false;

								// Go one with the appointment cancellation.
							}
						}

						$this->syncing = true;

						// Update appointment status to cancelled.
						$appointment->update_status( WC_Appointments_Constants::STATUS_CANCELLED );
						$appointment->save();

						$this->syncing = false;

						// Sync to other calendars withc multi-calendar sync.
						// Sync to site calendar.
						if ( $this->get_user_id() ) {
							$site_calendar_id  = get_option( 'wc_appointments_gcal_calendar_id' );
							// Site calendar exists?
							if ( $site_calendar_id ) {
								// Switch calendar back to global for new sync.
								$this->set_calendar_id( $site_calendar_id );
								$this->set_user_id( 0 ); #reset to global calendar sync.

								// Testing.
								#wc_add_appointment_log( $this->id, 'Removal of appointment #' . $appointment_eid . ' from Google Calendar #' . $this->get_calendar_id() );

								$this->remove_from_gcal( $appointment_eid );
							}
						// Sync to other staff members.
						} elseif ( [] !== $staff_ids ) {
							foreach ( $staff_ids as $staff_id ) {
								$calendar_id       = get_user_meta( $staff_id, 'wc_appointments_gcal_calendar_id', true );
								$staff_calendar_id = $calendar_id ?: '';
								// Staff must have calendar ID set.
								// Skip sync to the same calendar again.
								if ( $staff_calendar_id && $this->get_calendar_id() !== $staff_calendar_id ) {
									// Switch calendar to staff for new sync.
									$this->set_calendar_id( $staff_calendar_id );
									$this->set_user_id( $staff_id ); #reset to staff calendar sync.

									// Testing.
									#wc_add_appointment_log( $this->id, 'Removal of appointment #' . $appointment_eid . ' from Google Calendar #' . $this->get_calendar_id() . ' for staff #' . $staff_id );

									$this->remove_from_gcal( $appointment_eid, $staff_id, $staff_calendar_id, false );
								}
							}

							// Reset to global calendar sync.
							$site_calendar_id  = get_option( 'wc_appointments_gcal_calendar_id' );
							$this->set_calendar_id( $site_calendar_id );
							$this->set_user_id( 0 );
						}

						// Debug.
						if ( 'yes' === $this->get_debug() ) {
							if ( $this->get_user_id() ) {
								wc_add_appointment_log( $this->id, 'Successfully cancelled appointment #' . $appointment_eid . ' from Google Calendar event #' . $event['id'] . ' for staff #' . $this->get_user_id() );
							} else {
								wc_add_appointment_log( $this->id, 'Successfully cancelled appointment #' . $appointment_eid . ' from Google Calendar event #' . $event['id'] );
							}
						}
					// Update appointment data.
					} else {
						// Get appointment object.
						$appointment = get_wc_appointment( $appointment_eid );

						// Don't proceed if ID is not of a valid appointment.
						if ( ! is_a( $appointment, 'WC_Appointment' ) ) {
							continue;
						}

						// Testing.
						#wc_add_appointment_log( $this->id, 'Appointment 1 data: ' . var_export( $appointment, true ) );
						#wc_add_appointment_log( $this->id, 'Appointment start-end: ' . var_export( absint( date( 'YmdHis', $appointment->get_start() ) ) . '-' . absint( date( 'YmdHis', $appointment->get_end() ) ), true ) );
						#wc_add_appointment_log( $this->id, 'Event start-end: ' . var_export( absint( $dtstart->format( 'YmdHis' ) ) . '-' . absint( $dtend->format( 'YmdHis' ) ), true ) );
						#wc_add_appointment_log( $this->id, 'Appointment local timestamp start-end: ' . var_export( absint( strtotime( $dtstart->format( 'Y-m-d H:i:s' ) ) ) . '-' . absint( strtotime( $dtend->format( 'Y-m-d H:i:s' ) ) ), true ) );

						// Skip if event has no valid dates (can happen with some cancelled events).
						if ( null === $dtstart || null === $dtend ) {
							continue;
						}

						// Skip to next event if appointment times are the same.
						if (
							absint( date( 'YmdHis', $appointment->get_start() ) ) === absint( $dtstart->format( 'YmdHis' ) ) &&
							absint( date( 'YmdHis', $appointment->get_end() ) ) === absint( $dtend->format( 'YmdHis' ) )
						) {
							continue;
						}

						// Get staff IDs.
						$staff_ids = $appointment->get_staff_ids();

						// Testing.
						#wc_add_appointment_log( $this->id, 'Staff 2 IDs: ' . var_export( $staff_ids, true ) );

						$this->syncing = true;

						// Set appointment start and end date with events time and timezone.
						// Do not use $dtstart->getTimestamp()" as it does not work correctly.
						$appointment->set_start( absint( strtotime( $dtstart->format( 'Y-m-d H:i:s' ) ) ) );
						$appointment->set_end( absint( strtotime( $dtend->format( 'Y-m-d H:i:s' ) ) ) );
						$appointment->set_all_day( intval( $all_day ) );

						// Testing.
						#wc_add_appointment_log( $this->id, 'Appointment get start-end: ' . var_export( absint( date( 'YmdHis', $appointment->get_start() ) ) . '-' . absint( date( 'YmdHis', $appointment->get_end() ) ), true ) );

						// Update appointment event ID if saved
						// in extendedProperties of the event.
						if ( $appointment_eid ) {
							if ( $this->get_user_id() ) {
								$appointment->set_google_calendar_staff_event_ids( [ $this->get_user_id() => $event['id'] ] );
							} else {
								$appointment->set_google_calendar_event_id( wc_clean( $event['id'] ) );
							}
						}

						// Testing.
						#wc_add_appointment_log( $this->id, 'Appointment 2 data: ' . var_export( $appointment, true ) );

						// Save the changes.
						$appointment->save();

						// Keep syncing flag true during multi-calendar sync to prevent loop-back.
						// Sync to other calendars with multi-calendar sync.
						// Sync to site calendar.
						if ( $this->get_user_id() ) {
							$site_calendar_id  = get_option( 'wc_appointments_gcal_calendar_id' );
							// Site calendar exists?
							if ( $site_calendar_id ) {
								// Switch calendar back to global for new sync.
								$this->set_calendar_id( $site_calendar_id );
								$this->set_user_id( 0 ); #reset to global calendar sync.

								// Testing.
								#wc_add_appointment_log( $this->id, 'Sync appointment #' . $appointment_eid . ' to Google Calendar #' . $this->get_calendar_id() );

								$this->sync_to_gcal( $appointment_eid, false, false, true ); #true - sync other staff as well.
							}
							// Sync to other staff members.
						} elseif ( [] !== $staff_ids ) {
							foreach ( $staff_ids as $staff_id ) {
								$calendar_id       = get_user_meta( $staff_id, 'wc_appointments_gcal_calendar_id', true );
								$staff_calendar_id = $calendar_id ?: '';
								// Staff must have calendar ID set.
								// Skip sync to the same calendar again.
								if ( $staff_calendar_id && $this->get_calendar_id() !== $staff_calendar_id ) {
									// Switch calendar to staff for new sync.
									$this->set_calendar_id( $staff_calendar_id );
									$this->set_user_id( $staff_id ); #reset to staff calendar sync.

									// Testing.
									#wc_add_appointment_log( $this->id, 'Sync appointment #' . $appointment_eid . ' to Google Calendar #' . $this->get_calendar_id() . ' for staff #' . $staff_id );

									$this->sync_to_gcal( $appointment_eid, $staff_id, $staff_calendar_id, true ); #true - sync other staff as well.
								}
							}

							// Reset to global calendar sync.
							$site_calendar_id  = get_option( 'wc_appointments_gcal_calendar_id' );
							$this->set_calendar_id( $site_calendar_id );
							$this->set_user_id( 0 );
						}

						// Reset syncing flag after all sync operations complete.
						$this->syncing = false;

						// Debug.
						if ( 'yes' === $this->get_debug() ) {
							if ( $this->get_user_id() ) {
								wc_add_appointment_log( $this->id, 'Successfully updated appointment #' . $appointment_eid . ' from Google Calendar event #' . $event['id'] . ' for staff #' . $this->get_user_id() );
							} else {
								wc_add_appointment_log( $this->id, 'Successfully updated appointment #' . $appointment_eid . ' from Google Calendar event #' . $event['id'] );
							}
						}
					}

					// Go to next event.
					continue;
				}

				$availability = get_wc_appointments_availability();
				if ( 'CANCELLED' !== strtoupper( $event['status'] ) ) {
					if ( ! $this->update_availability_from_event( $availability, $event ) ) {
						// Debug: Event has invalid date format, skip.
						if ( 'yes' === $this->get_debug() ) {
							wc_add_appointment_log( $this->id, 'Skipping event #' . $event['id'] . ' - invalid date format' );
						}
						continue;
					}
					$availability->save();

					// Debug.
					if ( 'yes' === $this->get_debug() ) {
						if ( $this->get_user_id() ) {
							wc_add_appointment_log( $this->id, 'Successfully created availability rule from Google Calendar event #' . $event['id'] . ' for staff #' . $this->get_user_id() );
						} else {
							wc_add_appointment_log( $this->id, 'Successfully created availability rule from Google Calendar event #' . $event['id'] );
						}
					}
				}

				continue;
			}

			// Don't save as availability rule if event is from appointment.
			if ( $appointment_eid ) {
				continue;
			}

			// Loop through availability rules.
			// Update rules or delete them.
			foreach ( $availabilities as $availability ) {
				$event_date        = new WC_DateTime( $event['updated'] );
				$availability_date = $availability->get_date_modified();

				// Testing.
				#wc_add_appointment_log( $this->id, 'Event #' . $event['id'] . ' date #' . var_export( $event_date, true ) );
				#wc_add_appointment_log( $this->id, 'Availability #' . $event['id'] . ' date #' . var_export( $availability_date, true ) );
				#wc_add_appointment_log( $this->id, 'Event #' . $event['id'] . ' :' . var_export( $event, true ) );

				if ( $event_date > $availability_date ) {
					// Sync Google Event -> Availability.
					if ('CANCELLED' !== strtoupper( $event['status'] )) {
                        if ( ! $this->update_availability_from_event( $availability, $event ) ) {
							// Debug: Event has invalid date format, skip.
							if ( 'yes' === $this->get_debug() ) {
								wc_add_appointment_log( $this->id, 'Skipping event #' . $event['id'] . ' - invalid date format' );
							}
							continue;
						}
                        $availability->save();
                        // Debug.
                        if ( 'yes' === $this->get_debug() ) {
							if ( $this->get_user_id() ) {
								wc_add_appointment_log( $this->id, 'Successfully updated availability rule from Google Calendar event #' . $event['id'] . ' for staff #' . $this->get_user_id() );
							} else {
								wc_add_appointment_log( $this->id, 'Successfully updated availability rule from Google Calendar event #' . $event['id'] );
							}
						}
                    } elseif (isset( $event['recurringEventId'] ) && ! empty( $event['recurringEventId'] )) {
                        // Handle cancelled instances of recurring events as exceptions.
                        // This is a cancelled instance of a recurring event.
                        // Fetch the parent recurring event to get the complete EXDATE list.
                        $parent_event = $this->fetch_parent_recurring_event( $event['recurringEventId'] );
                        if ( $parent_event ) {
								// Update availability from parent event (which includes EXDATE).
								if ( $this->update_availability_from_event( $availability, $parent_event ) ) {
									$availability->save();
								}

								// Debug.
								if ( 'yes' === $this->get_debug() ) {
									if ( $this->get_user_id() ) {
										wc_add_appointment_log( $this->id, 'Successfully updated availability rule #' . $availability->get_id() . ' with exceptions from parent event #' . $event['recurringEventId'] . ' for staff #' . $this->get_user_id() );
									} else {
										wc_add_appointment_log( $this->id, 'Successfully updated availability rule #' . $availability->get_id() . ' with exceptions from parent event #' . $event['recurringEventId'] );
									}
								}
							} else {
								// Fallback: add this specific exception if we can't fetch parent.
								$exception_date = $this->get_exception_date_from_event( $event );
								if ( $exception_date ) {
									$this->add_exception_to_availability( $availability, $exception_date );
									$availability->save();

									// Debug.
									if ( 'yes' === $this->get_debug() ) {
										if ( $this->get_user_id() ) {
											wc_add_appointment_log( $this->id, 'Successfully added exception date ' . $exception_date . ' to availability rule #' . $availability->get_id() . ' for staff #' . $this->get_user_id() );
										} else {
											wc_add_appointment_log( $this->id, 'Successfully added exception date ' . $exception_date . ' to availability rule #' . $availability->get_id() );
										}
									}
								}
							}
                    } else {
							// This is a non-recurring event or the entire recurring series is cancelled - delete the rule.
							$deleted_event_id = $availability->get_event_id();
							$availability->delete();

							// Debug.
							if ( 'yes' === $this->get_debug() ) {
								if ( $this->get_user_id() ) {
									wc_add_appointment_log( $this->id, 'Successfully deleted availability rule #' . $availability->get_id() . ' for staff #' . $this->get_user_id() );
								} else {
									wc_add_appointment_log( $this->id, 'Successfully deleted availability rule #' . $availability->get_id() );
								}
							}

							// CASCADE DELETE: Also delete any availability rules created from modified instances
							// of this parent recurring event.
							if ( $deleted_event_id ) {
								$this->cascade_delete_modified_instance_rules( $deleted_event_id );
							}
						}
				}
			}

			// Add event to counter.
			if ( 'CANCELLED' !== strtoupper( $event['status'] ) ) {
				$gcal_count[] = $event['id'];
			}
		}

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			if ( $this->get_user_id() ) {
				#wc_add_appointment_log( $this->id, 'Sync from Google Calendar for staff #' . $this->get_user_id() . ' is successful.' ); #debug
			} else {
				#wc_add_appointment_log( $this->id, 'Sync from Google Calendar is successful.' ); #debug
			}
		}

		// Event ids for counting.
		return $gcal_count;
	}

	/**
	 * Fetch parent recurring event from Google Calendar.
	 *
	 * @param string $recurring_event_id Parent recurring event ID.
	 *
	 * @return array|null Parent event data or null on failure.
	 */
	private function fetch_parent_recurring_event( string $recurring_event_id ) {
		// Check if authorized.
		$access_token = $this->get_access_token();
		if ( ! $access_token ) {
			return null;
		}

		$calendar_id = $this->get_calendar_id();
		if ( ! $calendar_id ) {
			return null;
		}

		$api_url = $this->calendars_uri . rawurlencode( $calendar_id ) . '/events/' . rawurlencode( $recurring_event_id );

		$params = [
			'method'    => 'GET',
			'sslverify' => true,
			'timeout'   => 20,
			'headers'   => [
				'Content-Type'  => 'application/json',
				'Authorization' => 'Bearer ' . $access_token,
			],
		];

		$response = wp_safe_remote_request( $api_url, $params );

		if ( is_wp_error( $response ) ) {
			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, 'Error fetching parent recurring event #' . $recurring_event_id . ': ' . $response->get_error_message() );
			}
			return null;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, 'Failed to fetch parent recurring event #' . $recurring_event_id . ' (HTTP ' . $code . ')' );
			}
			return null;
		}

		$body = wp_remote_retrieve_body( $response );
		$event_data = json_decode( $body, true );

		if ( ! is_array( $event_data ) || empty( $event_data['id'] ) ) {
			return null;
		}

		return $event_data;
	}

	/**
	 * Cascade delete availability rules created from modified instances of a parent recurring event.
	 *
	 * When a parent recurring event is deleted in Google Calendar, any availability rules
	 * that were created for modified instances (rescheduled occurrences) should also be deleted.
	 *
	 * @param string $parent_event_id The parent recurring event ID.
	 */
	private function cascade_delete_modified_instance_rules( string $parent_event_id ): void {
		if ( empty( $parent_event_id ) ) {
			return;
		}

		/**
		 * @var WC_Appointments_Availability_Data_Store $availability_data_store
		 */
		$availability_data_store = WC_Data_Store::load( WC_Appointments_Availability::DATA_STORE );

		// Find all availability rules that have this event as their parent.
		$child_availabilities = $availability_data_store->get_all(
			[
				[
					'key'     => 'parent_event_id',
					'compare' => '=',
					'value'   => $parent_event_id,
				],
			],
		);

		if ( empty( $child_availabilities ) ) {
			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				wc_add_appointment_log( $this->id, 'No modified instance rules found for parent event #' . $parent_event_id );
			}
			return;
		}

		// Debug.
		if ( 'yes' === $this->get_debug() ) {
			wc_add_appointment_log( $this->id, 'CASCADE DELETE: Found ' . count( $child_availabilities ) . ' modified instance rule(s) for parent event #' . $parent_event_id );
		}

		foreach ( $child_availabilities as $child_availability ) {
			$child_id = $child_availability->get_id();
			$child_event_id = $child_availability->get_event_id();
			$child_availability->delete();

			// Debug.
			if ( 'yes' === $this->get_debug() ) {
				if ( $this->get_user_id() ) {
					wc_add_appointment_log( $this->id, 'CASCADE DELETE: Deleted modified instance availability rule #' . $child_id . ' (event: ' . $child_event_id . ') for staff #' . $this->get_user_id() );
				} else {
					wc_add_appointment_log( $this->id, 'CASCADE DELETE: Deleted modified instance availability rule #' . $child_id . ' (event: ' . $child_event_id . ')' );
				}
			}
		}
	}

	/**
	 * Extract exception date from a cancelled recurring event instance.
	 *
	 * @param array $event Google calendar event.
	 *
	 * @return string|null Exception date in EXDATE format or null.
	 */
	private function get_exception_date_from_event( array $event ) {
		if ( ! isset( $event['originalStartTime'] ) ) {
			return null;
		}

		// Check if all day event.
		$all_day = isset( $event['originalStartTime']['date'] );

		// Get site TimeZone.
		$wp_appointments_timezone = wc_timezone_string();

		if ( $all_day ) {
			// For all-day events, use date format (VALUE=DATE).
			$date = new WC_DateTime( $event['originalStartTime']['date'] );
			return $date->format( 'Ymd' );
		}
        // For timed events, use datetime format in UTC.
        $date = new WC_DateTime( $event['originalStartTime']['dateTime'] );
        // Convert to UTC for EXDATE
        $date->setTimezone( new DateTimeZone( $wp_appointments_timezone ) );
        return $date->format( 'Y-m-d\TH:i:sP' );
        #\DateTime::RFC3339

	}

	/**
     * Add exception date to availability rule.
     *
     * @param WC_Appointments_Availability $availability Availability rule.
     * @param string                       $exception_date Exception date.
     */
    private function add_exception_to_availability( WC_Appointments_Availability $availability, $exception_date ): void {
		$rrule = $availability->get_rrule();

		if ( empty( $rrule ) ) {
			return;
		}

		// Parse existing RRULE to check for EXDATE.
		$lines = array_filter( array_map( 'trim', explode( "\n", $rrule ) ) );
		$exdate_lines = [];
		$other_lines = [];

		foreach ( $lines as $line ) {
			if ( strpos( $line, 'EXDATE' ) === 0 ) {
				$exdate_lines[] = $line;
			} else {
				$other_lines[] = $line;
			}
		}

		// Collect existing exception dates, handling both VALUE=DATE and VALUE=DATE-TIME formats.
		$existing_exceptions = [];
		foreach ( $exdate_lines as $exdate_line ) {
			// Parse EXDATE;VALUE=DATE:date1,date2 or EXDATE:date1,date2 format.
			if ( preg_match( '/^EXDATE(?:;[^:]*)?:(.+)$/', $exdate_line, $matches ) ) {
				$dates = array_map( 'trim', explode( ',', $matches[1] ) );
				$existing_exceptions = array_merge( $existing_exceptions, $dates );
			}
		}

		// Add new exception if not already present.
		if ( ! in_array( $exception_date, $existing_exceptions, true ) ) {
			$existing_exceptions[] = $exception_date;
		} else {
			// Exception already exists, no need to update.
			return;
		}

		// Sort exceptions chronologically for cleaner output.
		sort( $existing_exceptions );

		// Determine if we need VALUE=DATE or not (check format of first exception).
		$needs_value_date = [] !== $existing_exceptions && strlen( $existing_exceptions[0] ) === 8;

		// Build new EXDATE line(s) - PHP RRule library requires separate EXDATE lines, not comma-separated.
		// Each exception date gets its own EXDATE line for proper parsing.
		$exdate_prefix = $needs_value_date ? 'EXDATE;VALUE=DATE:' : 'EXDATE:';
		foreach ( $existing_exceptions as $exception ) {
			$other_lines[] = $exdate_prefix . $exception;
		}

		// Reconstruct RRULE with updated EXDATE lines.
		$availability->set_rrule( implode( "\n", $other_lines ) );
	}

	/**
	 * Sanitize a recurring rule to make sure the date + time formats match up.
	 *
	 * @param string                        $rrule Recurring Rule.
	 * @param Google_Service_Calendar_Event $event Google calendar event object.
	 *
	 * @return string
	 */
	private function maybe_sanitize_rrule( string $rrule, bool $all_day ): ?string {
		// If we have only a start date then make sure the UNTIL also only has a date.
		if ($all_day) {
            return preg_replace( '/(UNTIL=\d{8})T\d{6}[^;]*/', '$1', $rrule );
        }

		return $rrule;
	}

	/**
     * Update global availability object with data from google event object.
     *
     * @param WC_Appointments_Availability $availability WooCommerce Appointments Availability object.
     * @param array $event Google calendar event.
     * @param object $dtstart Google calendar event start date/time.
     * @param object $dtend Google calendar event end date/time.
     */
    private function update_availability_from_event( WC_Appointments_Availability $availability, array $event ): bool {
		// Check if all day event.
		// value = DATE for all day, otherwise time included.
		$all_day = isset( $event['start']['date'] ) && isset( $event['end']['date'] );

		// Validate that event has required date properties.
		if ( ! isset( $event['start'] ) || ! isset( $event['end'] ) ) {
			return false;
		}

		// Check if BUSY or FREE.
		// value = OPAQUE for busy, and TRANSPARENT for free
		#$yes_no = isset( $event['transparency'] ) && 'TRANSPARENT' === strtoupper( $event['transparency'] ) ? 'yes' : 'no';
		$yes_no = 'no';

		// Get site TimeZone.
		$wp_appointments_timezone = wc_timezone_string();

		if ( $all_day ) {
			// Get Start and end date information
			$dtstart = new DateTime( $event['start']['date'] );
			$dtend   = new DateTime( $event['end']['date'] );
			$dtend->modify( '-1 second' ); #reduce 1 sec from end date.
		} elseif ( isset( $event['start']['dateTime'] ) && isset( $event['end']['dateTime'] ) ) {
			// Get Start and end datetime information
			$dtstart = new DateTime( $event['start']['dateTime'] );
			$dtstart->setTimezone( new DateTimeZone( $wp_appointments_timezone ) );
			$dtend = new DateTime( $event['end']['dateTime'] );
			$dtend->setTimezone( new DateTimeZone( $wp_appointments_timezone ) );
		} else {
			// Event has no valid date format.
			return false;
		}

		$availability->set_event_id( $event['id'] )
			->set_title( $event['summary'] ?? __( 'Busy', 'woocommerce-appointments' ) )
			->set_appointable( $yes_no )
			->set_priority( 5 )
			->set_ordering( 0 );

		if ( $this->get_user_id() ) {
			$availability->set_kind( WC_Appointments_Availability::KIND_STAFF );
			$availability->set_kind_id( $this->get_user_id() );
		} else {
			$availability->set_kind( WC_Appointments_Availability::KIND_GLOBAL );
		}

		// @TODO: check timezones.
		if ( isset( $event['recurrence'] ) ) {

			$availability->set_range_type( 'rrule' );
			// Join all recurrence rules including RRULE and EXDATE.
			$recurrence_string = implode( "\n", $event['recurrence'] );
			$availability->set_rrule( $this->maybe_sanitize_rrule( $recurrence_string, $all_day ) );
			if ( $all_day ) {
				$availability->set_from_range( $dtstart->format( 'Y-m-d' ) );
				$availability->set_to_range( $dtend->format( 'Y-m-d' ) );
			} else {
				$availability->set_from_range( $dtstart->format( 'Y-m-d\TH:i:sP' ) ); // \DateTime::RFC3339
				$availability->set_to_range( $dtend->format( 'Y-m-d\TH:i:sP' ) ); // \DateTime::RFC3339
			}

		} elseif ( $all_day ) {

			$availability->set_range_type( 'custom' )
				->set_from_range( $dtstart->format( 'Y-m-d' ) )
				->set_to_range( $dtend->format( 'Y-m-d' ) );

		} else {

			$availability->set_range_type( 'custom:daterange' )
				->set_from_date( $dtstart->format( 'Y-m-d' ) )
				->set_to_date( $dtend->format( 'Y-m-d' ) )
				->set_from_range( $dtstart->format( 'H:i' ) )
				->set_to_range( $dtend->format( 'H:i' ) );

		}

		return true;
	}

	/**
	 * Maybe delete Global Availability from Google.
	 *
	 * @param WC_Appointments_Availability $availability Availability to delete.
	 */
	public function delete_availability( WC_Appointments_Availability $availability ): void {
		if ( $availability->get_event_id() ) {
			// Set staff ID and staff calendar ID
			// if event is from staff availability.
			if ( 'availability#staff' === $availability->get_kind() && $availability->get_kind_id() ) {
				$this->set_user_id( $availability->get_kind_id() );
			}

			// Set parameters for gcal request.
			$calendar_id = $this->get_calendar_id() ?: 0;
			$api_url     = $this->calendars_uri . $calendar_id . '/events/' . $availability->get_event_id();
			$user_id     = $this->get_user_id() ?: 0;
			$params      = [
				'method' => 'DELETE',
			];

			try {

				$response = $this->make_gcal_request( $api_url, $params, $user_id );

				// Nothing to work on.
				if ( is_wp_error( $response ) || ! $response ) {
					return;
				}

				// Event already deleted.
				if ( 410 === $response['response']['code'] ) {
					return;
				}

				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					if ( $this->get_user_id() ) {
						wc_add_appointment_log( $this->id, 'Successfully deleted event #' . $availability->get_event_id() . ' from Google for staff #' . $this->get_user_id() );
					} else {
						wc_add_appointment_log( $this->id, 'Successfully deleted event #' . $availability->get_event_id() . ' from Google' );
					}
				}

			} catch ( Exception $e ) {

				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					if ( $this->get_user_id() ) {
						wc_add_appointment_log( $this->id, 'Error while deleting event #' . $availability->get_event_id() . ' from Google for staff #' . $this->get_user_id() . ':' . $e->getMessage() );
					} else {
						wc_add_appointment_log( $this->id, 'Error while deleting event #' . $availability->get_event_id() . ' from Google: ' . $e->getMessage() );
					}
				}
			}
		}
	}

	/**
	 * Sync Global Availability to Google.
	 *
	 * @param WC_Appointments_Availability $availability Global Availability object.
	 */
	public function sync_availability( WC_Appointments_Availability $availability ): void {
		if ( ! $availability->get_changes() ) {
			// nothing changed don't waste time syncing.
			return;
		}

		if ( $this->syncing ) {
			// Event is coming from google don't send it back.
			return;
		}

		if ( $availability->get_event_id() ) {
			// Set staff ID and staff calendar ID
			// if event is from staff availability.
			if ( 'availability#staff' === $availability->get_kind() && $availability->get_kind_id() ) {
				$this->set_user_id( $availability->get_kind_id() );
			}

			// Set parameters for gcal request.
			$calendar_id = $this->get_calendar_id() ?: 0;
			$api_url     = $this->calendars_uri . $calendar_id . '/events/' . $availability->get_event_id();
			$user_id     = $this->get_user_id() ?: 0;
			$params      = [
				'method' => 'GET',
			];
			$json_data   = false;
			$event_data  = false;

			try {

				$response = $this->make_gcal_request( $api_url, $params, $user_id );

				// Nothing to work on.
				if ( is_wp_error( $response ) || ! $response ) {
					return;
				}

				$json_data = json_decode( $response['body'], true );

				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					if ( $this->get_user_id() ) {
						#wc_add_appointment_log( $this->id, 'Successfully got event #' . $availability->get_event_id() . ' from Google for staff #' . $this->get_user_id() );
					} else {
						#wc_add_appointment_log( $this->id, 'Successfully got event #' . $availability->get_event_id() . ' from Google' );
					}
				}

			} catch ( Exception $e ) {

				// Debug.
				if ( 'yes' === $this->get_debug() ) {
					if ( $this->get_user_id() ) {
						wc_add_appointment_log( $this->id, 'Error while getting event #' . $availability->get_event_id() . ' from Google for staff #' . $this->get_user_id() . ':' . $e->getMessage() );
					} else {
						wc_add_appointment_log( $this->id, 'Error while getting event #' . $availability->get_event_id() . ' from Google: ' . $e->getMessage() );
					}
				}
			}

			// Only update events created in Gcal.
			// @TODO maybe add site rules to gcal as new events.
			if ( $json_data ) {
				$event      = $json_data;
				$event_data = $this->update_event_from_availability( $event, $availability );

				// Skip update of 'rrule' type of rules.
				if ( $event_data ) {

					// Set parameters for gcal request.
					$params = [
						'method' => 'PUT',
						'body'   => wp_json_encode( $event_data ),
					];

					try {

						$response = $this->make_gcal_request( $api_url, $params, $user_id );

						// Debug.
						if ( 'yes' === $this->get_debug() ) {
							if ( $this->get_user_id() ) {
								wc_add_appointment_log( $this->id, 'Successfully updated event #' . $event_data['id'] . ' with Google for staff #' . $this->get_user_id() );
							} else {
								wc_add_appointment_log( $this->id, 'Successfully updated event #' . $event_data['id'] . ' with Google' );
							}
						}

					} catch ( Exception $e ) {

						// Debug.
						if ( 'yes' === $this->get_debug() ) {
							if ( $this->get_user_id() ) {
								wc_add_appointment_log( $this->id, 'Error while updating event #' . $event_data['id'] . ' with Google for staff #' . $this->get_user_id() . ':' . $e->getMessage() );
							} else {
								wc_add_appointment_log( $this->id, 'Error while updating event #' . $event_data['id'] . ' with Google: ' . $e->getMessage() );
							}
						}
					}
				}
			}
		}
	}

	/**
     * Update google event object with data from global availability object.
     *
     * @param array  $event Google calendar event.
     * @param WC_Appointments_Availability $availability WooCommerce Global Availability object.
     *
     * @return mixed[]|null
     */
    private function update_event_from_availability( array $event, WC_Appointments_Availability $availability ): ?array {
		$timezone        = wc_timezone_string();
		$start_date_time = new WC_DateTime();
		$end_date_time   = new WC_DateTime();

		$event['summary'] = $availability->get_title();

		switch ( $availability->get_range_type() ) {
			case 'custom:daterange':
				$start_date_time = new WC_DateTime( $availability->get_from_date() . ' ' . $availability->get_from_range() );
				$event['start']  = [
					'dateTime' => $start_date_time->format( 'Y-m-d\TH:i:s' ),
					'timeZone' => $timezone,
				];

				$end_date_time = new WC_DateTime( $availability->get_to_date() . ' ' . $availability->get_to_range() );
				$event['end']  = [
					'dateTime' => $end_date_time->format( 'Y-m-d\TH:i:s' ),
					'timeZone' => $timezone,
				];

				break;
			case 'custom':
				$start_date_time = new WC_DateTime( $availability->get_from_range() );
				$event['start']  = [
					'date' => $start_date_time->format( 'Y-m-d' ),
				];

				$end_date_time = new WC_DateTime( $availability->get_to_range() );
				$end_date_time->add( new DateInterval( 'P1D' ) );
				$event['end'] = [
					'date' => $end_date_time->format( 'Y-m-d' ),
				];

				break;
			case 'months':
				$start_date_time->setDate(
				    date( 'Y' ),
				    $availability->get_from_range(),
				    1,
				);

				$event['start'] = [
					'date' => $start_date_time->format( 'Y-m-d' ),
				];

				$number_of_months = 1 + intval( $availability->get_to_range() ) - intval( $availability->get_from_range() );

				$end_date_time = $start_date_time->add( new DateInterval( 'P' . $number_of_months . 'M' ) );

				$event['end'] = [
					'date' => $end_date_time->format( 'Y-m-d' ),
				];

				$event['recurrence'] = [ 'RRULE:FREQ=YEARLY' ];

				break;
			case 'weeks':
				$start_date_time->setDate(
				    date( 'Y' ),
				    1,
				    1,
				);

				$end_date_time->setDate(
				    date( 'Y' ),
				    1,
				    2,
				);

				$all_days     = implode( ',', array_keys( \RRule\RRule::WEEKDAYS ) );
				$week_numbers = implode( ',', range( $availability->get_from_range(), $availability->get_to_range() ) );
				$rrule        = "RRULE:FREQ=YEARLY;BYWEEKNO=$week_numbers;BYDAY=$all_days";

				$event['start'] = [
					'date' => $start_date_time->format( 'Y-m-d' ),
				];

				$event['end'] = [
					'date' => $end_date_time->format( 'Y-m-d' ),
				];

				$event['recurrence'] = [ $rrule ];

				break;
			case 'days':
				$start_day = intval( $availability->get_from_range() );
				$end_day   = intval( $availability->get_to_range() );

				$start_date_time->modify( 'this ' . self::DAYS_OF_WEEK[ $start_day ] );
				$event['start'] = [
					'date' => $start_date_time->format( 'Y-m-d' ),
				];

				$end_date_time = $start_date_time->modify( 'this ' . self::DAYS_OF_WEEK[ $end_day ] );

				$event['end'] = [
					'date' => $end_date_time->format( 'Y-m-d' ),
				];

				$event['recurrence'] = [ 'RRULE:FREQ=WEEKLY' ];

				break;
			case 'time:1':
			case 'time:2':
			case 'time:3':
			case 'time:4':
			case 'time:5':
			case 'time:6':
			case 'time:7':
				[, $day_of_week] = explode( ':', $availability->get_range_type() );

				$start_date_time->modify( 'this ' . self::DAYS_OF_WEEK[ $day_of_week ] );
				$end_date_time->modify( 'this ' . self::DAYS_OF_WEEK[ $day_of_week ] );
				$rrule = 'RRULE:FREQ=WEEKLY';

				// fall through please.
			case 'time':
				if ( ! isset( $rrule ) ) {
					$rrule = 'RRULE:FREQ=DAILY';
				}

				[$start_hour, $start_min] = explode( ':', $availability->get_from_range() );
				$start_date_time->setTime( $start_hour, $start_min );

				[$end_hour, $end_min] = explode( ':', $availability->get_to_range() );
				$end_date_time->setTime( $end_hour, $end_min );

				$event['start'] = [
					'dateTime' => $start_date_time->format( 'Y-m-d\TH:i:s' ),
					'timeZone' => $timezone,
				];

				$event['end'] = [
					'dateTime' => $end_date_time->format( 'Y-m-d\TH:i:s' ),
					'timeZone' => $timezone,
				];

				$event['recurrence'] = [ $rrule ];

				break;

			default:
				// That should be everything, anything else is not supported.
				return null;
		}

		return $event;
	}
}

if ( ! function_exists( 'wc_appointments_gcal' ) ) {
	/**
	 * Returns the main instance of WC_Appointments_GCal to prevent the need to use globals.
	 *
	 * @return WC_Appointments_GCal
	 */
	function wc_appointments_gcal() {
		return WC_Appointments_GCal::instance();
	}
}

wc_appointments_gcal();
