<?php
/**
 * Class WC_Bookings_Google_Calendar_Connection
 *
 * @package WooCommerce/Bookings
 */

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

use Automattic\WooCommerce\Bookings\Vendor\Google\Client as GoogleClient;
use Automattic\WooCommerce\Bookings\Vendor\Google\Service\Calendar as GoogleServiceCalendar;
use Automattic\WooCommerce\Bookings\Vendor\Google\Service\Calendar\Event as GoogleServiceCalendarEvent;
use Automattic\WooCommerce\Bookings\Vendor\Google\Service\Calendar\EventDateTime as GoogleServiceCalendarEventDateTime;
use Automattic\WooCommerce\Bookings\Vendor\RRule\RRule;

/**
 * Google Calendar Connection.
 *
 * @version 3.9.0
 */
class WC_Bookings_Google_Calendar_Connection extends WC_Settings_API {

	const CONNECT_WOOCOMMERCE_URL = WC_BOOKINGS_CONNECT_WOOCOMMERCE_URL;

	const TOKEN_TRANSIENT_TIME = 3500;

	const DAYS_OF_WEEK = array(
		1 => 'monday',
		2 => 'tuesday',
		3 => 'wednesday',
		4 => 'thursday',
		5 => 'friday',
		6 => 'saturday',
		7 => 'sunday',
	);

	/**
	 * The single instance of the class.
	 *
	 * @var $_instance
	 * @since 1.13.0
	 */
	protected static $_instance = null; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore

	/**
	 * Name for nonce to update calendar settings.
	 *
	 * @since 1.13.0
	 * @var string self::NONCE_NAME
	 */
	const NONCE_NAME = 'bookings_calendar_settings_nonce';

	/**
	 * Action name for nonce to update calendar settings.
	 *
	 * @since 1.13.0
	 * @var string self::NONCE_ACTION
	 */
	const NONCE_ACTION = 'submit_bookings_calendar_settings';

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

	/**
	 * WooCommerce Logger instance.
	 *
	 * @var WC_Logger_Interface
	 */
	protected $log;

	/**
	 * Google Service from SDK.
	 *
	 * @var GoogleServiceCalendar
	 */
	protected $service;

	/**
	 * Google Client ID.
	 *
	 * @var string
	 */
	private $client_id;

	/**
	 * Google Client Secret.
	 *
	 * @var string
	 */
	private $client_secret;

	/**
	 * Google Calendar Redirect URI.
	 *
	 * @var string
	 */
	private $redirect_uri_custom;

	/**
	 * Google Calendar Page Token.
	 *
	 * @var string
	 */
	private $page_token;

	/**
	 * Name for the option holds information about how many failures in a row we had.
	 * If the options is not defined then we assume it is 0.
	 *
	 * @since 1.15.15
	 */
	const POLLER_BACKOFF_FAILURE_STATE = 'wc_bookings_gcalendar_poller_failure_state';

	/**
	 * Name of the admin notice raised when the calendar list cannot be fetched.
	 *
	 * @since 3.9.0
	 */
	const CALENDAR_FETCH_ERROR_NOTICE = 'bookings_google_calendar_fetch_calendars_error';

	/**
	 * Number of minutes between poller fetches.
	 *
	 * @since 1.15.15
	 */
	const CALENDAR_DEFAULT_POLLER_RATE = 5;

	/**
	 * Poller retry delay progress when we experience fetch error.
	 *
	 * @since 1.15.15
	 */
	const CALENDAR_EXPONENTIAL_BACKOFF_RATES = array(
		self::CALENDAR_DEFAULT_POLLER_RATE,
		self::CALENDAR_DEFAULT_POLLER_RATE * 1,  // Schedule first retry using the same time.
		self::CALENDAR_DEFAULT_POLLER_RATE * 2,
		self::CALENDAR_DEFAULT_POLLER_RATE * 4,
		self::CALENDAR_DEFAULT_POLLER_RATE * 8,
	);

	/**
	 * Limit for the poller retry.
	 * Even if we will have more consecutive failures we will use this as a maximum value.
	 *
	 * @since 1.15.15
	 */
	const CALENDAR_POLLER_MAX_RETRY_RATE = 4; // Maximum index for CALENDAR_EXPONENTIAL_BACKOFF_RATES.

	/**
	 * Init and hook in the integration.
	 */
	private function __construct() {

		$this->plugin_id           = 'wc_bookings_';
		$this->id                  = 'google_calendar_wooconnect';
		$this->redirect_uri_custom = WC()->api_request_url( 'wc_bookings_google_calendar' );

		// Define user set variables.
		$this->client_id     = $this->get_option( 'client_id' );
		$this->client_secret = $this->get_option( 'client_secret' );

		$this->maybe_migrate_old_settings();

		// Actions.
		add_action( 'woocommerce_api_wc_bookings_google_calendar_wooconnect', array( $this, 'oauth_redirect' ) );
		add_action( 'woocommerce_api_wc_bookings_google_calendar', array( $this, 'oauth_redirect_custom' ) );

		add_action( 'init', array( $this, 'register_booking_update_hooks' ) );
		add_action( 'woocommerce_booking_in-cart_to_unpaid', array( $this, 'sync_cod_booking' ), 10, 2 );

		add_action( 'woocommerce_before_booking_global_availability_object_save', array( $this, 'sync_global_availability' ) );

		add_action( 'woocommerce_bookings_before_delete_global_availability', array( $this, 'delete_global_availability' ) );

		add_action( 'trashed_post', array( $this, 'remove_booking' ) );
		add_action( 'untrashed_post', array( $this, 'sync_untrashed_booking' ) );
		add_action( 'wc-booking-poll-google-cal', array( $this, 'poll_google_calendar_events' ) );
		add_action( 'init', array( $this, 'maybe_schedule_poller' ) );
		add_action( 'init', array( $this, 'maybe_schedule_deleted_events_cleanup' ) );
		add_action( 'woocommerce_bookings_cleanup_deleted_events', array( $this, 'cleanup_deleted_events' ) );
		add_action( 'init', array( $this, 'init_form_fields' ) );

		add_action( 'woocommerce_bookings_update_google_client', array( $this, 'maybe_enable_legacy_integration' ), 1, 5 );

		if ( is_admin() ) {
			add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
		}

		if ( ! empty( $_POST['wc_bookings_google_calendar_redirect'] ) && empty( $_POST['save'] ) ) {
			$this->process_calendar_redirect();
		}

		add_action( 'admin_notices', array( $this, 'show_auth_keys_changed_notice' ) );
		add_action( 'admin_notices', array( $this, 'handle_calendar_fetch_error_notice' ) );
	}

	/**
	 * Show warning to reconnect if the `BOOKINGS_ENCRYPTION_KEY` and `BOOKINGS_ENCRYPTION_SALT` constants
	 * are newly added.
	 *
	 * @since 2.0.5
	 */
	public function show_auth_keys_changed_notice() {
		$keys_updated  = get_option( 'wc_bookings_auth_key_updated', false );
		$auth_keys_set = WC_Bookings_Encryption::instance()->are_custom_bookings_auth_keys_set();
		$show_message  = ( $auth_keys_set && ! $keys_updated ) || ( ! $auth_keys_set && $keys_updated );

		if ( $show_message ) {
			WC_Admin_Notices::add_custom_notice(
				'bookings_auth_keys_changed_notice',
				'<strong>' . esc_html__( 'WooCommerce Bookings: Google Calendar was disconnected because authentication keys were changed. Please connect again.', 'woocommerce-bookings' ) . '</strong> '
			);
		}

		if ( ! $auth_keys_set && $keys_updated ) {
			delete_option( 'wc_bookings_auth_key_updated' );
		}
	}

	/**
	 * Hide calendar fetch error notice if not on the bookings settings page.
	 *
	 * @since 3.0.0
	 */
	public function handle_calendar_fetch_error_notice() {
		$screen_id = get_current_screen()->id;

		if ( 'wc_booking_page_wc_bookings_settings' !== $screen_id && WC_Admin_Notices::has_notice( self::CALENDAR_FETCH_ERROR_NOTICE ) ) {
			WC_Admin_Notices::remove_notice( self::CALENDAR_FETCH_ERROR_NOTICE );
		}
	}

	/**
	 * Process Calendar redirect
	 *
	 * @since 1.11
	 */
	public function process_calendar_redirect() {
		$client = $this->get_client();
		// We need this to get the refresh token every time.
		$client->setPrompt( 'consent' );
		$client->setClientId( $this->client_id );
		$client->setClientSecret( $this->client_secret );
		$client->setRedirectUri( $this->redirect_uri_custom );
		$auth_url = $client->createAuthUrl();
		wp_redirect( $auth_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
		exit;
	}

	/**
	 * Enqueues admin js scripts.
	 *
	 * @since 1.3.12
	 */
	public function enqueue_scripts() {
		wp_enqueue_script( 'wc_bookings_calendar_connection_scripts', WC_BOOKINGS_PLUGIN_URL . '/dist/admin-calendar-connection.js', wc_booking_get_script_dependencies( 'admin-calendar-connection' ), WC_BOOKINGS_VERSION, true );
	}

	/**
	 * Get configured calendar id.
	 *
	 * @return string
	 */
	protected function get_calendar_id() {
		return $this->get_option( 'calendar_id' );
	}

	/**
	 * Get configured sync preference
	 *
	 * @return string
	 */
	protected function get_sync_preference() {
		return $this->get_option( 'sync_preference' );
	}

	/**
	 * Returns true if event deletion is preferred when the
	 * corresponding store availability is deleted, false
	 * otherwise.
	 *
	 * @return boolean
	 */
	protected function is_event_deletion_preferred() {
		return 'yes' === $this->get_option( 'event_deletion_preference' );
	}

	/**
	 * Returns true if overwriting the Google Event Description
	 * is allowed, false otherwise.
	 *
	 * @since 1.15.49
	 * @return bool
	 */
	protected function is_event_description_overwritable() {
		return 'yes' === $this->get_option( 'description_overwrite' );
	}

	/**
	 * Get WC_Logger if enabled.
	 *
	 * @return WC_Logger|null
	 */
	protected function get_logger() {
		if ( null === $this->log && 'yes' === $this->get_option( 'debug' ) ) {
			if ( class_exists( 'WC_Logger' ) ) {
				$this->log = new WC_Logger();
			} else {
				$this->log = WC()->logger();
			}
		}
		return $this->log;
	}

	/**
	 * Logging method.
	 *
	 * @param string $message Log message.
	 * @param array  $context Optional. Additional information for log handlers.
	 * @param string $level   Log level.
	 *                        Available options: 'emergency', 'alert',
	 *                        'critical', 'error', 'warning', 'notice',
	 *                        'info' and 'debug'.
	 *                        Defaults to 'info'.
	 * @param bool   $show_notice Whether to show a notice to the user.
	 */
	private function log( $message, $context = array(), $level = WC_Log_Levels::NOTICE, $show_notice = false ) {
		$logger = $this->get_logger();
		if ( is_null( $logger ) ) {
			return;
		}

		if ( ! isset( $context['source'] ) ) {
			$context['source'] = $this->id;
		}

		$logger->log( $level, $message, $context );

		// Show notice.
		if ( $show_notice ) {
			// Add a custom link to the support page.
			$message .= '; ';

			$support_text = sprintf(
				/* translators: %1s: support link start tag, %2s: support link end tag */
				esc_html__(
					'If things aren\'t going quite as expected, we\'re happy to help -- please %1$1sreach out to our support team%2$2s.',
					'woocommerce-bookings'
				),
				'<a href="https://woocommerce.com/my-account/contact-support/" target="_blank">',
				'</a>'
			);

			/**
			 * Filter the support text shown in the admin notice.
			 *
			 * @since 2.0.8
			 *
			 * @param string $support_text Support text.
			 * @param string $message      Log message.
			 * @param string $level        Log level.
			 *
			 * @return string Support text.
			 */
			$support_text = apply_filters( 'woocommerce_bookings_google_calendar_connection_support_text', $support_text, $message, $level ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceVersionComment

			// Append support text to message.
			$message .= $support_text;

			WC_Admin_Notices::add_custom_notice(
				'bookings_google_calendar_connection',
				'<strong>' . esc_html__( 'WooCommerce Bookings:', 'woocommerce-bookings' ) . '</strong> ' . $message
			);
		}
	}

	/**
	 * $this->id changed so we need to migrate any old settings that may exist.
	 */
	private function maybe_migrate_old_settings() {
		$existing_options = get_option( $this->get_option_key(), null );
		if ( null !== $existing_options || ! $this->is_integration_active() ) {
			return; // Already migrated or existing config never setup.
		}
		$old_settings = get_option( 'wc_bookings_google_calendar_settings', null );

		if ( ! empty( $old_settings ) ) {
			unset( $old_settings['client_id'] ); // Client id and secret not used anymore.
			unset( $old_settings['client_secret'] );
			add_option( $this->get_option_key(), $old_settings );
		}
	}

	/**
	 * Returns WC_Bookings_Google_Calendar_Settings singleton
	 *
	 * Ensures only one instance of WC_Bookings_Google_Calendar_Settings is created.
	 *
	 * @since 1.13.0
	 * @return WC_Bookings_Google_Calendar_Connection - Main instance.
	 */
	public static function instance() {
		if ( is_null( self::$_instance ) ) {
			self::$_instance = new self();
		}

		return self::$_instance;
	}

	/**
	 * Update settings values from form.
	 *
	 * @since 1.13.0
	 */
	public function maybe_save_settings() {
		if ( isset( $_POST[ self::NONCE_NAME ] )
			&& wp_verify_nonce( wc_clean( wp_unslash( $_POST[ self::NONCE_NAME ] ) ), self::NONCE_ACTION ) ) {
			$this->process_admin_options();

			if ( isset( $_POST['Submit'] ) ) {
				do_action( 'wc_bookings_calendar_settings_on_save', $this );

				echo '<div class="updated"><p>' . esc_html__( 'Settings saved.', 'woocommerce-bookings' ) . '</p></div>';
			}
		}
	}

	/**
	 * Generates full HTML form for the instance settings.
	 *
	 * @since 1.13.0
	 */
	public static function generate_form_html() {
		self::instance()->maybe_save_settings();
		?>
			<form method="post" action="" id="bookings_settings">
				<?php self::instance()->admin_options(); ?>
				<p class="submit">
					<input type="submit" name="Submit" class="button-primary" value="<?php esc_attr_e( 'Save Changes', 'woocommerce-bookings' ); ?>" />
					<?php wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME ); ?>
				</p>
			</form>
		<?php
	}

	/**
	 * Set a delay for the next poller request in case we had an error on the previous request.
	 *
	 * This is a very simplified exponential backoff request function. It is simplified because we don't do
	 * active re-tries in the case of a failure. This means that we don't need advance algorithm but just
	 * an exponentially growing set of retry times. We also don't need to add the random amount to ensure
	 * out of sync requests between sites. The sites poller is already out of sync by default.
	 *
	 * @since 1.15.15
	 */
	protected function poller_error_backoff_engine( $request_status ) {
		// Delete option if success. We only track the failures.
		if ( 'success' === $request_status ) {
			delete_option( self::POLLER_BACKOFF_FAILURE_STATE );
			return;
		} elseif ( 'failure' === $request_status ) {
			// We could get rid of else but we want to force calling the function with proper parameters.
			$poller_errors_count = get_option( self::POLLER_BACKOFF_FAILURE_STATE, 0 ) + 1;
			update_option( self::POLLER_BACKOFF_FAILURE_STATE, $poller_errors_count );
			$this->log(
				sprintf(
					'Events synchronisation number of consecutive errors: %d.',
					$poller_errors_count
				),
				array(),
				WC_Log_Levels::ERROR,
				true
			);
		}
		// Reschedule next action.
		$this->maybe_schedule_poller();
	}

	/**
	 * Returns value of poller interval that will be used for next poll.
	 *
	 * @return integer Poller interval in seconds.
	 *
	 * @since 1.15.15
	 */
	public function get_poller_interval() {
		$default_interval           = apply_filters( 'woocommerce_bookings_gcalendar_poll_interval', self::CALENDAR_DEFAULT_POLLER_RATE ); // minutes.
		$poller_failures            = min( get_option( self::POLLER_BACKOFF_FAILURE_STATE, 0 ), self::CALENDAR_POLLER_MAX_RETRY_RATE );
		$failures_adjusted_interval = self::CALENDAR_EXPONENTIAL_BACKOFF_RATES[ $poller_failures ];
		$interval                   = max( $default_interval, $failures_adjusted_interval ); // max in case the user has self defined a bigger interval.

		return $interval * MINUTE_IN_SECONDS; // seconds.
	}

	/**
	 * Attempt to schedule/unschedule poller once AS is ready.
	 */
	public function maybe_schedule_poller() {
		if ( ! $this->is_integration_active() || 'both_ways' !== $this->get_sync_preference() ) {
			as_unschedule_all_actions( 'wc-booking-poll-google-cal' );
			return;
		}

		$poll_interval_seconds = $this->get_poller_interval();

		/*
		 * If the poll_interval was changed either by filter or code we need to update the exiting recurring action interval.
		 * The ActionScheduler will not do that on its own. We need to detect that there was a change. If there was we
		 * need to stop all existing actions and reschedule new ones.
		 * The detection process works as follows:
		 * 1. Get all pending wc-booking-poll-google-cal actions.
		 * 2. Take the last one.
		 * 3. Get the internal interval and compare it with poll_interval_seconds.
		 * 4  If they differ unschedule all recurring wc-booking-poll-google-cal actions and schedule new ones.
		 */

		$actions = as_get_scheduled_actions(
			array(
				'hook'   => 'wc-booking-poll-google-cal',
				'status' => ActionScheduler_Store::STATUS_PENDING,
				'group'  => 'bookings',
			)
		);

		if ( ! empty( $actions ) ) {
			$last = end( $actions );

			$last_interval = $last->get_schedule()->get_recurrence();

			if ( $last_interval != $poll_interval_seconds ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
				as_unschedule_all_actions( 'wc-booking-poll-google-cal' );
			}
		}

		if ( ! as_next_scheduled_action( 'wc-booking-poll-google-cal' ) ) {
			as_unschedule_all_actions( 'wc-booking-poll-google-cal' );
			as_schedule_recurring_action( time(), $poll_interval_seconds, 'wc-booking-poll-google-cal', array(), 'bookings' );
		}
	}

	/**
	 * Schedule a recurring Action Scheduler every 6 months to cleanup
	 * the option `woocommerce_bookings_cleanup_deleted_events` which
	 * stores an array of deleted options.
	 */
	public function maybe_schedule_deleted_events_cleanup() {
		/**
		 * Return if Google events should be deleted with Global
		 * availability.
		 */
		if ( $this->is_event_deletion_preferred() ) {
			return;
		}

		if ( false === as_next_scheduled_action( 'woocommerce_bookings_cleanup_deleted_events' ) ) {
			as_schedule_recurring_action( time(), MONTH_IN_SECONDS, 'woocommerce_bookings_cleanup_deleted_events' );
		}
	}

	/**
	 * Deletes deleted events before 'today' from option `woocommerce_bookings_deleted_global_availability`.
	 * 'today' is the day when this method is scheduled to run.
	 */
	public function cleanup_deleted_events() {
		$deleted_availabilities = get_option( 'woocommerce_bookings_deleted_global_availability', array() );

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

		$timezone    = new DateTimeZone( wc_booking_get_timezone_string() );
		$todays_date = new WC_DateTime( 'now', $timezone );
		$todays_date = $todays_date->date( 'Y-m-d' );

		foreach ( $deleted_availabilities as $event_id => $event_end_date ) {
			if ( $event_end_date < $todays_date ) {
				unset( $deleted_availabilities[ $event_id ] );
			}
		}

		update_option( 'woocommerce_bookings_deleted_global_availability', $deleted_availabilities );
	}

	/**
	 * Registers booking object lifecycle events.
	 * Needs to happen after init because of the dynamic hook names.
	 */
	public function register_booking_update_hooks() {
		foreach ( $this->get_booking_is_paid_statuses() as $status ) {
			// We have to do it this way because of the dynamic hook name.
			add_action( 'woocommerce_booking_' . $status, array( $this, 'sync_new_booking' ) );
		}

		add_action( 'woocommerce_booking_cancelled', array( $this, 'remove_booking' ) );
		add_action( 'woocommerce_booking_process_meta', array( $this, 'sync_edited_booking' ) );
		add_action( 'woocommerce_booking_rescheduled', array( $this, 'sync_rescheduled_booking' ) );
	}

	/**
	 * Syncs bookings to Google Calendar that are made using COD
	 * payment method.
	 *
	 * @param int        $booking_id Booking id.
	 * @param WC_Booking $booking    Booking object.
	 * @see https://github.com/woocommerce/woocommerce-bookings/issues/2755#issuecomment-829304974
	 */
	public function sync_cod_booking( $booking_id, $booking ) {
		/**
		 * Filter to enable/disable syncing of bookings that are made
		 * using COD payment method.
		 *
		 * @param int        $booking_id Booking id.
		 * @param WC_Booking $booking    Booking object.
		 */
		if ( ! apply_filters( 'woocommerce_booking_sync_cod_bookings', true, $booking_id, $booking ) ) {
			return;
		}

		$order = $booking->get_order();

		if ( false === $order ) {
			return;
		}

		$payment_method = $order->get_payment_method();

		// Only sync with google calendar if payment method is `COD`.
		if ( 'cod' === $payment_method ) {
			$this->sync_booking( $booking_id );
		}
	}

	/**
	 * Returns an authorized API client.
	 *
	 * @since 1.16.02 Decryption of access token before setting it to client.
	 * @return GoogleClient the authorized client object
	 */
	protected function get_client() {
		$client = new GoogleClient();
		$client->setApplicationName( 'WooCommerce Bookings Google Calendar Integration' );
		$client->setScopes( GoogleServiceCalendar::CALENDAR );
		$access_token = get_transient( 'wc_bookings_gcalendar_access_token' );
		if ( isset( $access_token['access_token'] ) ) {
			$access_token['access_token'] = WC_Bookings_Encryption::instance()->decrypt( $access_token['access_token'] );
		}

		$refresh_token = WC_Bookings_Encryption::instance()->decrypt( get_option( 'wc_bookings_gcalendar_refresh_token' ) );

		$client->setAccessType( 'offline' );
		$client->setState( wp_create_nonce( 'wc_bookings_google_calendar_wooconnect' ) );

		do_action( 'woocommerce_bookings_update_google_client', $client );

		if ( get_option( 'wc_bookings_google_calendar_custom_connection' ) ) {
			$client->setRedirectUri( WC()->api_request_url( 'wc_bookings_google_calendar' ) );
		} else {
			$client->setRedirectUri( WC()->api_request_url( 'wc_bookings_google_calendar_wooconnect' ) );
		}
		// Refresh the token if it's expired. Note that we need a refresh token for this.
		if ( $refresh_token && empty( $access_token ) ) {
			try {
				$access_token = $this->renew_access_token( $refresh_token, $client );
			} catch ( Exception $e ) {
				$access_token = null;
			}

			if ( $access_token && isset( $access_token['access_token'] ) ) {
				unset( $access_token['refresh_token'] ); // unset this since we store it in an option.

				// Encrypt the access token before storing it in the transient.
				$decrypted_access_token       = $access_token['access_token'];
				$access_token['access_token'] = WC_Bookings_Encryption::instance()->encrypt( $decrypted_access_token );

				set_transient( 'wc_bookings_gcalendar_access_token', $access_token, self::TOKEN_TRANSIENT_TIME );

				// Decrypt the access token before setting it on the client.
				$access_token['access_token'] = $decrypted_access_token;
			} else {
				$this->log(
					sprintf(
						'Unable to fetch access token with refresh token. Google sync disabled until re-authenticated. Error: "%s", "%s"',
						isset( $access_token['error'] ) ? $access_token['error'] : '',
						isset( $access_token['error_description'] ) ? $access_token['error_description'] : ''
					),
					array(),
					WC_Log_Levels::ERROR,
					true
				);
			}
		}

		// It may be empty, e.g. in case refresh token is empty.
		if ( ! empty( $access_token ) ) {
			$access_token['refresh_token'] = $refresh_token;
			try {
				$client->setAccessToken( $access_token );
			} catch ( InvalidArgumentException $e ) {
				// Something is wrong with the access token, customer should try to connect again.
				$this->log( sprintf( 'Invalid access token. Reconnect with Google necessary. Code %s. Message: %s.', $e->getCode(), $e->getMessage() ), array(), WC_Log_Levels::ERROR, true );
			}
		}

		return $client;
	}

	/**
	 * Activates Google Integration without api.woocommerce.com/integrations if site was previously setup with it's own app.
	 *
	 * @param GoogleClient $client Google Client App.
	 */
	public function maybe_enable_legacy_integration( GoogleClient $client ) {
		$legacy_settings = get_option( 'wc_bookings_google_calendar_settings', null );

		if ( $legacy_settings && $this->is_integration_active() &&
			! empty( $legacy_settings['client_id'] ) && ! empty( $legacy_settings['client_secret'] ) ) {
			$client->setClientId( $legacy_settings['client_id'] );
			$client->setClientSecret( $legacy_settings['client_secret'] );
		}
	}

	public function is_using_wooconnect_method() {
		$wooconnect_method = get_option( 'wc_bookings_google_calendar_wooconnect_method_connection', null );
		if ( $wooconnect_method ) {
			return true;
		}
		$custom_connect_method = get_option( 'wc_bookings_google_calendar_custom_connection', null );
		if ( $custom_connect_method ) {
			return false;
		}
		$access_token  = $this->get_client()->getAccessToken();
		$refresh_token = WC_Bookings_Encryption::instance()->decrypt( get_option( 'wc_bookings_gcalendar_refresh_token' ) );
		if ( $refresh_token && ! $access_token ) {
			return false;
		}

		$doing_logout = ! empty( $_POST['wc_bookings_google_calendar_wooconnect_authorization'] ) && 'logout' === $_POST['wc_bookings_google_calendar_wooconnect_authorization'];
		if ( null === $custom_connect_method && null === $wooconnect_method && ( ! $doing_logout && $this->is_integration_active() ) ) {
			// This is the edge case of still using the old setup with migration.
			return true;
		}
		return false;
	}

	public function is_using_custom_connection() {
		$custom_method = get_option( 'wc_bookings_google_calendar_custom_connection', null );
		return $custom_method && $this->is_integration_active();
	}

	/**
	 * Set a new sync token (used when Google returns one)
	 *
	 * @param string $sync_token Google sync token.
	 */
	protected function set_sync_token( $sync_token ) {
		$sync_token = WC_Bookings_Encryption::instance()->encrypt( $sync_token );
		set_transient( 'wc_bookings_gcalendar_sync_token', $sync_token, self::TOKEN_TRANSIENT_TIME );
	}

	/**
	 * Get sync token.
	 *
	 * @return string
	 */
	protected function get_sync_token() {
		$sync_token = get_transient( 'wc_bookings_gcalendar_sync_token' );
		if ( ! empty( $sync_token ) ) {
			$sync_token = WC_Bookings_Encryption::instance()->decrypt( $sync_token );
		}
		return $sync_token;
	}

	/**
	 * This is called by API requesters. We are not doing it on the constructor
	 * as it takes some time to init the service, so only init when necessary.
	 */
	protected function maybe_init_service() {
		if ( empty( $this->service ) ) {
			$this->service = new GoogleServiceCalendar( $this->get_client() );
		}
	}

	/**
	 * Get Google Events (paginated)
	 *
	 * @param array $params Current parameters.
	 * @return array
	 */
	protected function get_event_page( $params = array() ) {
		$this->maybe_init_service();

		$request_params = array(
			'timeZone' => wc_booking_get_timezone_string(),
		);
		if ( ! empty( $this->page_token ) ) {
			$request_params              = $params;
			$request_params['pageToken'] = $this->page_token;
		} else {
			$sync_token = $this->get_sync_token();
			if ( ! empty( $sync_token ) ) {
				$request_params['syncToken'] = $sync_token;
				if ( isset( $params['maxResults'] ) ) {
					$request_params['maxResults'] = $params['maxResults'];
				}
			} else {
				$request_params = $params;
			}
		}

		try {
			// Block next request for at least next 4:55.
			set_transient( 'wc-booking-poll-google-cal-barrier', true, ( MINUTE_IN_SECONDS * 5 ) - 5 );
			$results = $this->service->events->listEvents( $this->get_calendar_id(), $request_params );
		} catch ( Exception $e ) {
			return array(
				'events'   => array(),
				'has_next' => false,
				'error'    => $e->getCode(),
			);
		}

		$this->page_token = $results->getNextPageToken();

		$sync_token = $results->getNextSyncToken();
		if ( ! empty( $sync_token ) ) {
			$this->set_sync_token( $sync_token );
		}

		return array(
			'events'   => $results->getItems(),
			'has_next' => empty( $sync_token ),
			'error'    => 0,
		);
	}

	/**
	 * Get a list of calendar events.
	 *
	 * @return array
	 */
	public function get_events() {
		$events = array();

		$params = apply_filters(
			'woocommerce_bookings_gcal_events_request',
			array(
				'singleEvents' => false,
				'timeMin'      => date( 'c' ),
				'timeMax'      => date( 'c', strtotime( 'now +2 years' ) ),
				'timeZone'     => wc_booking_get_timezone_string(),
			)
		);

		do {
			$page_result = $this->get_event_page( $params );

			// Full sync case.
			if ( 410 === (int) $page_result['error'] ) {
				$page_result['has_next'] = true;
				$this->set_sync_token( '' ); // Unset expired token.
				continue; // Repeat same request.
			}

			if ( 0 !== (int) $page_result['error'] ) {
				$this->log( $page_result['error'] );
				$this->poller_error_backoff_engine( 'failure' );
				// TODO: Unhandled error. Handle it somehow.
			} else {
				$this->poller_error_backoff_engine( 'success' );
			}

			$events = array_merge( $events, $page_result['events'] );
		} while ( $page_result['has_next'] ); // Final page will include a syncToken.

		return $events;
	}

	/**
	 * Method for polling data from Google API.
	 *
	 * Sync path: Google API -> Bookings
	 * The sync path Bookings -> Google API will be handled by `action` and `filter` events.
	 */
	public function poll_google_calendar_events() {
		if ( 'both_ways' !== $this->get_sync_preference() || ! $this->is_integration_active() ) {
			return;
		}

		try {
			if ( get_transient( 'wc-booking-poll-google-cal-barrier' ) ) {
				return;
			}
			$this->log( 'Getting Google Calendar Events from Google Calendar API...' );
			$this->polling = true;
			/**
			 * Global Availability Data store instance.
			 *
			 * @var WC_Global_Availability_Data_Store $global_availability_data_store
			 */
			$global_availability_data_store = WC_Data_Store::load( WC_Global_Availability::DATA_STORE );

			$events = $this->get_events();

			$deleted_availabilities = get_option( 'woocommerce_bookings_deleted_global_availability', array() );

			foreach ( $events as $event ) {
				/**
				 * If a global store availability was deleted once, then prevent
				 * the event from being imported from Google calendar once again.
				 */
				if ( isset( $deleted_availabilities[ $event['id'] ] ) ) {
					continue;
				}

				$availabilities = $global_availability_data_store->get_all(
					array(
						array(
							'key'     => 'gcal_event_id',
							'value'   => $event['id'],
							'compare' => '=',
						),
					)
				);

				if ( empty( $availabilities ) ) {

					$booking_ids = WC_Booking_Data_Store::get_booking_ids_by( array( 'google_calendar_event_id' => $event['id'] ) );

					if ( ! empty( $booking_ids ) ) {
						// Google event is an existing booking not a manually created event for the global availability.
						// Ignore changes for now in future we may allow editing bookings from google calendar.
						continue;
					}

					// If no global availability found, just create one.
					$global_availability = new WC_Global_Availability();
					if ( 'cancelled' !== $event->getStatus() ) {
						$this->update_global_availability_from_event( $global_availability, $event );
						$global_availability->save();
					}

					continue;
				}

				foreach ( $availabilities as $availability ) {
					$event_date        = new WC_DateTime( $event['updated'] );
					$availability_date = $availability->get_date_modified();

					if ( $event_date > $availability_date ) {
						// Sync Google Event -> Global Availability.
						if ( 'cancelled' !== $event->getStatus() ) {

							$this->update_global_availability_from_event( $availability, $event );
							$availability->save();
						} else {
							$availability->delete();
						}
					}
				}
			}
		} catch ( Exception $e ) {
			$this->log( 'Error while getting list of events', array(), WC_Log_Levels::ERROR, true );
		}
		$this->polling = false;
	}

	/**
	 * Initialize integration settings form fields.
	 *
	 * @return void
	 */
	public function init_form_fields() {
		$this->form_fields = array(
			'authorization'                => array(
				'title' => __( 'Authorization', 'woocommerce-bookings' ),
				'type'  => 'google_calendar_authorization',
			),
			'testing'                      => array(
				'title'       => __( 'Connect with a custom Google Calendar App', 'woocommerce-bookings' ),
				'type'        => 'title',
				// translators: %1$s - line breaks, %2$s - URL to use as authorised redirect URI
				'description' => sprintf( __( 'Enter the credentials below to use a custom Google Calendar API app. Disconnect existing connection to enter credentials.%1$sAdd %2$s to the Authorized redirect URIs of your Google Cloud App.', 'woocommerce-bookings' ), '<br /><br />', '<code>' . WC()->api_request_url( 'wc_bookings_google_calendar' ) . '</code>' ),
			),
			'client_id'                    => array(
				'title'       => __( 'Client ID', 'woocommerce-bookings' ),
				'type'        => 'text',
				'description' => __( 'Enter the Google Client ID associated with your Calendar API app.', 'woocommerce-bookings' ),
				// Resolved in resolve_remote_form_field_values() — is_using_wooconnect_method()
				// can trigger a blocking Google token refresh, and this method
				// runs on `init` for every request.
				'disabled'    => false,
				'desc_tip'    => true,
				'default'     => '',
			),
			'client_secret'                => array(
				'title'       => __( 'Client Secret', 'woocommerce-bookings' ),
				'type'        => 'text',
				'description' => __( 'Enter the Google Client Secret associated with your Calendar API app.', 'woocommerce-bookings' ),
				// Resolved in resolve_remote_form_field_values(), see client_id above.
				'disabled'    => false,
				'desc_tip'    => true,
				'default'     => '',
			),
			'custom_authorization'         => array(
				'title' => __( 'Authorization', 'woocommerce-bookings' ),
				'type'  => 'custom_google_calendar_authorization',
			),
			'calendar_connection_settings' => array(
				'title'         => __( 'Connected Calendar Settings', 'woocommerce-bookings' ),
				'type'          => 'title',
				'display_check' => array( $this, 'display_connection_settings' ),
			),
			'calendar_id'                  => array(
				'title'         => __( 'Calendar', 'woocommerce-bookings' ),
				'type'          => 'select',
				'description'   => __( 'Select your Calendar.', 'woocommerce-bookings' ),
				'desc_tip'      => true,
				'default'       => '',
				// Populated in resolve_remote_form_field_values() — resolving the calendar
				// list here would make a blocking Google API call on every
				// request, since this method runs on `init`.
				'options'       => array(),
				'display_check' => array( $this, 'display_connection_settings' ),
			),
			'sync_preference'              => array(
				'type'          => 'select',
				'title'         => __( 'Sync Preference', 'woocommerce-bookings' ),
				'options'       => array(
					'both_ways' => __( 'Sync both ways - between Store and Google', 'woocommerce-bookings' ),
					'one_way'   => __( 'Sync one way - from Store to Google', 'woocommerce-bookings' ),
				),
				'description'   => __( 'Manage the sync flow between your Store calendar and Google calendar.', 'woocommerce-bookings' ),
				'desc_tip'      => true,
				'default'       => 'one_way',
				'display_check' => array( $this, 'display_connection_settings' ),
			),
			'event_deletion_preference'    => array(
				'title'       => __( 'Deletion Preference', 'woocommerce-bookings' ),
				'type'        => 'checkbox',
				'label'       => __( 'Delete Events from Google Calendar', 'woocommerce-bookings' ),
				'default'     => 'no',
				'description' => __( 'Check this to delete an event from Google Calendar when the corresponding store availability is deleted.', 'woocommerce-bookings' ),
			),
			'debug'                        => array(
				'title'       => __( 'Debug Log', 'woocommerce-bookings' ),
				'type'        => 'checkbox',
				'label'       => __( 'Enable logging', 'woocommerce-bookings' ),
				'default'     => 'no',
				/* translators: 1: log file path */
				'description' => sprintf( __( 'Log Google Calendar events, such as API requests, inside %s', 'woocommerce-bookings' ), '<code>woocommerce/logs/' . $this->id . '-' . sanitize_file_name( wp_hash( $this->id ) ) . '.txt</code>' ),
			),
			'description_overwrite'        => array(
				'title'       => __( 'Overwrite Description', 'woocommerce-bookings' ),
				'type'        => 'checkbox',
				'label'       => __( 'Enable overwrite', 'woocommerce-bookings' ),
				'default'     => 'no',
				'description' => __( 'When enabled any changes to the event description must be done from the WooCommerce Bookings event. Any changes made directly to the event description in Google Calendar will be lost when data is synced.', 'woocommerce-bookings' ),
			),
		);
	}

	/**
	 * Resolve the remote form-field values and display a fetch error raised too late for `admin_notices`.
	 *
	 * WC_Admin_Notices snapshots its notice list on `admin_print_styles`, and
	 * this settings tab renders from the page body, after `admin_notices` has
	 * already run. A fetch error raised while resolving would therefore sit in
	 * the option until the next admin page load. Resolving here, before any of
	 * the form markup is emitted, lets that error be displayed on the request
	 * that caused it; the stored notice still shows, with its dismiss link, on
	 * subsequent loads.
	 *
	 * @since 3.9.0
	 */
	protected function maybe_output_late_calendar_fetch_notice() {
		if ( empty( $this->form_fields ) ) {
			return;
		}

		// A notice already present was displayed on `admin_notices`; only one
		// raised by the resolution below still needs displaying.
		$had_notice = WC_Admin_Notices::has_notice( self::CALENDAR_FETCH_ERROR_NOTICE );

		try {
			$this->resolve_remote_form_field_values();
		} catch ( Exception $e ) {
			// Mirrors admin_options(): the Google client can throw outside the
			// fetch that raises the notice, and that must not be fatal.
			$this->log( 'The Google Calendar API is facing issues: ' . $e->getMessage(), array(), WC_Log_Levels::ERROR, true );
			return;
		}

		if ( $had_notice || ! WC_Admin_Notices::has_notice( self::CALENDAR_FETCH_ERROR_NOTICE ) ) {
			return;
		}

		$notice_html = get_option( 'woocommerce_admin_notice_' . self::CALENDAR_FETCH_ERROR_NOTICE );

		if ( $notice_html ) {
			printf( '<div class="notice notice-error"><p>%s</p></div>', wp_kses_post( $notice_html ) );
		}
	}

	/**
	 * Resolve the form-field values that require a Google API call.
	 *
	 * The calendar_id options (a calendarList.list call) and the
	 * client_id/client_secret disabled flag (is_using_wooconnect_method(),
	 * which can trigger a token refresh) are deliberately left empty/false
	 * by init_form_fields(), which runs on `init` for every request. They are
	 * resolved here instead, from admin_options(), so only a render of the
	 * settings tab pays for them.
	 *
	 * @since 3.9.0
	 */
	protected function resolve_remote_form_field_values() {
		if ( isset( $this->form_fields['calendar_id'] ) && empty( $this->form_fields['calendar_id']['options'] ) ) {
			$this->form_fields['calendar_id']['options'] = $this->get_calendar_list_options();
		}
		if ( isset( $this->form_fields['client_id'] ) || isset( $this->form_fields['client_secret'] ) ) {
			$using_wooconnect = $this->is_using_wooconnect_method();
			foreach ( array( 'client_id', 'client_secret' ) as $credential_field ) {
				if ( isset( $this->form_fields[ $credential_field ] ) ) {
					$this->form_fields[ $credential_field ]['disabled'] = $using_wooconnect;
				}
			}
		}
	}

	/**
	 * Generate Settings HTML.
	 *
	 * Extends base class html generation to add 'display_check' parameter to each
	 * field. 'display_check', is a callable that enables/disables the display of
	 * the field.
	 *
	 * @param array $form_fields (default: array()) Array of form fields.
	 * @param bool  $echo Echo or return.
	 * @return string the html for the settings
	 * @since  1.15.0
	 */
	public function generate_settings_html( $form_fields = array(), $echo = true ) {
		if ( empty( $form_fields ) ) {
			$form_fields = $this->get_form_fields();
		}
		foreach ( $form_fields as $index => $field ) {
			// Delete fields if they have an "enable_check" function that returns false.
			if ( isset( $field['display_check'] ) && ! call_user_func( $field['display_check'] ) ) {
				unset( $form_fields[ $index ] );
			}
		}
		return parent::generate_settings_html( $form_fields, $echo );
	}

	/**
	 * Whether or not connection settings should be displayed.
	 *
	 * Checks Google connection status so connection settings can be hidden
	 * if there is no active action.
	 *
	 * @return bool Whether or not connection settings should be displayed.
	 * @since  1.15.0
	 */
	protected function display_connection_settings() {
		$access_token  = $this->get_client()->getAccessToken();
		$refresh_token = WC_Bookings_Encryption::instance()->decrypt( get_option( 'wc_bookings_gcalendar_refresh_token' ) );
		return $access_token && $refresh_token;
	}

	/**
	 * Return the Google auth URL for starting a connection.
	 *
	 * @since 3.2.0
	 * @return string
	 */
	public function get_connection_auth_url() {
		return $this->get_google_auth_url();
	}

	/**
	 * Whether the connection is active and has valid tokens.
	 *
	 * @since 3.2.0
	 * @return bool
	 */
	public function has_valid_connection() {
		return $this->display_connection_settings();
	}

	/**
	 * Disconnect the current Google Calendar integration.
	 *
	 * @since 3.2.0
	 * @return bool
	 */
	public function disconnect() {
		$this->log( 'Disconnecting from the Google Calendar app...' );

		if ( $this->is_using_custom_connection() ) {
			$success = $this->oauth_logout_custom();
			if ( $success ) {
				delete_option( 'wc_bookings_google_calendar_custom_connection' );
				$this->log( 'Disconnected from the Google Calendar app successfully.' );
			}
			return $success;
		}

		$client       = $this->get_client();
		$access_token = $client->getAccessToken();
		$success      = true;

		if ( ! empty( $access_token['access_token'] ) ) {
			if ( $client->getClientId() ) {
				$success = (bool) $client->revokeToken( $access_token );
			} else {
				$response = wp_remote_post(
					self::CONNECT_WOOCOMMERCE_URL . '/revoke/google',
					array(
						'body' => array( 'access_token' => $access_token ),
					)
				);
				if ( is_wp_error( $response ) ) {
					$this->log( 'Failed to revoke token via WooCommerce Connect: ' . $response->get_error_message(), array(), WC_Log_Levels::ERROR, true );
					$success = false;
				} else {
					$body    = json_decode( wp_remote_retrieve_body( $response ), true );
					$success = ! empty( $body['success'] );
				}
			}
		}

		if ( $success ) {
			delete_option( 'wc_bookings_gcalendar_refresh_token' );
			delete_transient( 'wc_bookings_gcalendar_sync_token' );
			delete_transient( 'wc_bookings_gcalendar_access_token' );
			delete_option( 'wc_bookings_google_calendar_wooconnect_method_connection' );
			delete_option( 'wc_bookings_google_calendar_settings' );
			$this->log( 'Disconnected from the Google Calendar app successfully.' );
		} else {
			$this->log( 'Failed to revoke access token.', array(), WC_Log_Levels::ERROR, true );
		}

		return $success;
	}

	/**
	 * Get the list of available Google calendars.
	 *
	 * @since 3.2.0
	 * @return array Array of calendars with 'id', 'name', and 'primary' keys, or empty array on error.
	 */
	public function get_calendars() {
		if ( ! $this->is_integration_active() ) {
			return array();
		}

		$this->maybe_init_service();

		try {
			$calendar_list = $this->service->calendarList->listCalendarList( array( 'minAccessRole' => 'writer' ) )->items;
			$calendars     = array();

			foreach ( $calendar_list as $calendar ) {
				$calendars[] = array(
					'id'      => $calendar['id'],
					'name'    => $calendar['summary'],
					'primary' => ! empty( $calendar['primary'] ),
				);
			}

			return $calendars;
		} catch ( Exception $e ) {
			$this->log( 'Error while getting the list of calendars: ' . $e->getMessage(), array(), WC_Log_Levels::ERROR );
			return array();
		}
	}

	/**
	 * Get the currently selected calendar ID.
	 *
	 * @since 3.2.0
	 * @return string The selected calendar ID, or empty string if none selected.
	 */
	public function get_selected_calendar_id() {
		return $this->get_calendar_id();
	}

	/**
	 * Set the selected calendar ID.
	 *
	 * @since 3.2.0
	 * @param string $calendar_id The calendar ID to save.
	 * @return bool True on success, false on failure.
	 */
	public function set_selected_calendar_id( $calendar_id ) {
		// Ensure settings are loaded from DB so we do not overwrite other keys (e.g. sync_preference).
		$this->get_option( 'calendar_id' );
		$this->settings['calendar_id'] = sanitize_text_field( $calendar_id );
		return update_option( $this->get_option_key(), $this->settings, 'yes' );
	}

	/**
	 * Generate the Google Calendar Authorization field for self-owned Google Calendar apps.
	 *
	 * @param  mixed $key
	 * @param  array $data
	 *
	 * @return string
	 */
	public function generate_custom_google_calendar_authorization_html( $key, $data ) {
		$options           = $this->plugin_id . $this->id . '_';
		$client_id         = isset( $_POST[ $options . 'client_id' ] ) ? sanitize_text_field( wp_unslash( $_POST[ $options . 'client_id' ] ) ) : $this->client_id;
		$client_secret     = isset( $_POST[ $options . 'client_secret' ] ) ? sanitize_text_field( wp_unslash( $_POST[ $options . 'client_secret' ] ) ) : $this->client_secret;
		$access_token      = $this->get_client()->getAccessToken();
		$refresh_token     = WC_Bookings_Encryption::instance()->decrypt( get_option( 'wc_bookings_gcalendar_refresh_token' ) );
		$wooconnect_method = $this->is_using_wooconnect_method();

		ob_start();
		?>
		<tr valign="top">
			<th scope="row" class="titledesc">
				<?php echo wp_kses_post( $data['title'] ); ?>
			</th>
			<td class="forminp">
				<input type="hidden" name="wc_bookings_google_calendar_redirect" id="wc_bookings_google_calendar_redirect">
				<?php if ( ! $wooconnect_method && ( ! $client_id || ! $client_secret ) ) : ?>
					<p style="color:red;"><?php esc_html_e( 'Please fill out all required fields from above and save changes.', 'woocommerce-bookings' ); ?></p>
				<?php elseif ( ! $refresh_token || ( $refresh_token && ! $access_token ) || $wooconnect_method ) : ?>
					<p class="submit"><a class="button button-primary <?php echo $wooconnect_method ? ' disabled"' : ' wc-bookings-calendar-connect"'; ?> ><?php esc_html_e( 'Connect with custom Google app', 'woocommerce-bookings' ); ?></a></p>
				<?php else : ?>
					<p><?php esc_html_e( 'Successfully authenticated.', 'woocommerce-bookings' ); ?></p>
					<p class="submit"><a class="button button-primary" href="<?php echo esc_url( add_query_arg( array( 'logout' => 'true' ), $this->redirect_uri_custom ) ); ?>"><?php esc_html_e( 'Disconnect', 'woocommerce-bookings' ); ?></a></p>
				<?php endif; ?>
			</td>
		</tr>
		<?php
		return ob_get_clean();
	}

	/**
	 * Returns an array to feed the calendar list select input
	 *
	 * @return array
	 */
	private function get_calendar_list_options() {
		$this->maybe_init_service();
		$options = array( '' => __( 'Select a calendar from the list', 'woocommerce-bookings' ) );

		if ( $this->is_integration_active() ) {
			try {
				$result = array_reduce(
					$this->service->calendarList->listCalendarList()->items,
					function ( $carry, $item ) {
						$carry[ $item['id'] ] = $item['summary'];
						return $carry;
					},
					$options
				);
				// Remove any existing error notice since the operation succeeded.
				if ( WC_Admin_Notices::has_notice( self::CALENDAR_FETCH_ERROR_NOTICE ) ) {
					WC_Admin_Notices::remove_notice( self::CALENDAR_FETCH_ERROR_NOTICE );
				}
				return $result;
			} catch ( Exception $e ) {
				$this->log( 'Error while getting the list of calendars: ' . $e->getMessage(), array(), WC_Log_Levels::ERROR );
				WC_Admin_Notices::add_custom_notice(
					self::CALENDAR_FETCH_ERROR_NOTICE,
					sprintf(
						'<strong>%s</strong> %s',
						esc_html__( 'WooCommerce Bookings:', 'woocommerce-bookings' ),
						sprintf(
							/* translators: %s: Error message */
							esc_html__( 'Error while getting the list of calendars: %s', 'woocommerce-bookings' ),
							esc_html( $e->getMessage() )
						)
					)
				);
			}
		}

		return $options;
	}

	/**
	 * Validate the Google Calendar Authorization field.
	 * Really it performs the oauth_logout if the disconnect button is clicked.
	 *
	 * @param string $key Current Key.
	 * @param string $value Value of field.
	 *
	 * @return string
	 */
	public function validate_google_calendar_authorization_field( $key, $value ) {
		if ( 'logout' === $value ) {
			$this->oauth_logout();
		}
		return '';
	}

	/**
	 * Validate the the id and secret.
	 * If the integration is active and one of the fields was deleted then it means that we should log-out.
	 *
	 * @param string $key Current Key.
	 * @param string $value Value of field.
	 *
	 * @return string
	 */
	public function validate_text_field( $key, $value ) {
		if ( 'client_secret' === $key || 'client_id' === $key ) {
			if ( $this->is_integration_active() && '' === $value ) {
				$this->oauth_logout_custom();
			}
		}
		return parent::validate_text_field( $key, $value );
	}

	/**
	 * Generate the Google Calendar Authorization field.
	 *
	 * @param  mixed $key
	 * @param  array $data
	 *
	 * @return string
	 */
	public function generate_google_calendar_authorization_html( $key, $data ) {
		$access_token        = $this->get_client()->getAccessToken();
		$refresh_token       = WC_Bookings_Encryption::instance()->decrypt( get_option( 'wc_bookings_gcalendar_refresh_token' ) );
		$custom_oauth_active = $this->is_using_custom_connection();
		ob_start();
		?>
		<tr valign="top">
			<th scope="row" class="titledesc">
				<?php echo wp_kses_post( $data['title'] ); ?>
			</th>
			<td class="forminp">
				<?php if ( ! $refresh_token || ( $refresh_token && ! $access_token ) || $custom_oauth_active ) : ?>
					<p class="submit">
						<a id="wc_bookings_google_connect_btn" class="button button-primary<?php echo $custom_oauth_active ? ' disabled' : ''; ?>" <?php echo $custom_oauth_active ? ' disabled="disabled"' : ''; ?> href="<?php echo $custom_oauth_active ? '' : esc_url( $this->get_google_auth_url() ); ?>">
							<?php esc_html_e( 'Connect with Google', 'woocommerce-bookings' ); ?>
						</a>
					</p>
				<?php else : ?>
					<p><?php esc_html_e( 'Successfully authenticated.', 'woocommerce-bookings' ); ?></p>
					<p class="submit"><button class="button button-primary" name="<?php echo esc_attr( $this->get_field_key( $key ) ); ?>" value="logout"><?php esc_html_e( 'Disconnect', 'woocommerce-bookings' ); ?></button></p>
				<?php endif; ?>
			</td>
		</tr>
		<?php
		return ob_get_clean();
	}

	/**
	 * Admin Options.
	 */
	public function admin_options() {
		$this->maybe_output_late_calendar_fetch_notice();

		printf(
			/* translators: %1$s - opening <p> tag, %2$s - closing </p> tag, %3$s - opening <a> tag, %4$s - closing </a> tag */
			esc_html__(
				'%1$sTo sync with Google Calendar using an app provided by WooCommerce.com, click the "Connect with Google" button below to authorize access to your Google calendar. For more details, refer the %2$sofficial documentation%3$s.%4$s',
				'woocommerce-bookings'
			),
			'<p>',
			'<a href="https://woocommerce.com/document/introduction-to-woocommerce-bookings/woocommerce-booking-settings/woocommerce-booking-google-calendar-integration/" target="_blank">',
			'</a>',
			'</p>'
		);

		try {
			echo '<table class="form-table">';
			$this->generate_settings_html();
			echo '</table>';
		} catch ( Exception $e ) {
			$this->log( 'The Google Calendar API is facing issues: ' . $e->getMessage(), array(), WC_Log_Levels::ERROR, true );
		}
	}

	/**
	 * OAuth Logout.
	 *
	 * @return void
	 */
	protected function oauth_logout() {
		$this->log( 'Leaving the Google Calendar app...' );

		$client       = $this->get_client();
		$access_token = $client->getAccessToken();

		if ( ! empty( $access_token['access_token'] ) ) {
			if ( $client->getClientId() ) {
				$body = $client->revokeToken( $access_token );
			} else {
				$response = wp_remote_post(
					self::CONNECT_WOOCOMMERCE_URL . '/revoke/google',
					array(
						'body' => array( 'access_token' => $access_token ),
					)
				);
				$body     = json_decode( wp_remote_retrieve_body( $response ), true );
			}
			if ( $body['success'] ) {
				echo '<div class="updated fade"><p><strong>' . esc_html__( 'Google Calendar', 'woocommerce-bookings' ) . '</strong> ' . esc_html__( 'Account disconnected successfully!', 'woocommerce-bookings' ) . '</p></div>';
			} else {
				echo '<div class="error fade"><p><strong>' . esc_html__( 'Google Calendar', 'woocommerce-bookings' ) . '</strong> ' . esc_html__( 'Failed to disconnect to your account, please try again, if the problem persists, turn on Debug Log option and see what is happening.', 'woocommerce-bookings' ) . '</p></div>';
			}
		} else {
			echo '<div class="updated fade"><p><strong>' . esc_html__( 'Google Calendar', 'woocommerce-bookings' ) . '</strong> ' . esc_html__( 'Account not properly connected, reset successfully!', 'woocommerce-bookings' ) . '</p></div>';
		}

		delete_option( 'wc_bookings_gcalendar_refresh_token' );
		delete_transient( 'wc_bookings_gcalendar_sync_token' );
		delete_transient( 'wc_bookings_gcalendar_access_token' );
		delete_option( 'wc_bookings_google_calendar_wooconnect_method_connection' );

		// Delete legacy settings since we will resync with official app.
		delete_option( 'wc_bookings_google_calendar_settings' );

		$logger = $this->get_logger();
		if ( $logger ) {
			$logger->add( $this->id, 'Left the Google Calendar App. successfully' );
		}

		$redirect_args = array(
			'post_type' => 'wc_booking',
			'page'      => 'wc_bookings_settings',
			'tab'       => 'connection',
		);
		wp_redirect( add_query_arg( $redirect_args, admin_url( '/edit.php?' ) ), 301 ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
		exit;
	}

	/**
	 * Process the oauth redirect.
	 *
	 * @return void
	 */
	public function oauth_redirect() {
		$valid_nonce = isset( $_REQUEST['nonce'] ) ? wp_verify_nonce( sanitize_key( wp_unslash( $_REQUEST['nonce'] ) ), 'wc_bookings_google_calendar_wooconnect' ) : false;

		if ( ! current_user_can( 'manage_options' ) || ! $valid_nonce ) {
			wp_die( esc_html__( 'Permission denied!', 'woocommerce-bookings' ) );
		}

		$redirect_args = array(
			'post_type' => 'wc_booking',
			'page'      => 'wc_bookings_settings',
			'tab'       => 'connection',
		);

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		/*
		 * The access token, scope, token type and refresh token are validated by the
		 * Google API client during the authentication process so do not need to be
		 * sanitized by the various sanitization functions once unslashed.
		 */
		$access_token = array(
			'access_token'  => wp_unslash( $_GET['access_token'] ),
			'expires_in'    => wp_unslash( $_GET['expires_in'] ),
			'scope'         => wp_unslash( $_GET['scope'] ),
			'token_type'    => wp_unslash( $_GET['token_type'] ),
			'created'       => wp_unslash( $_GET['created'] ),
			'refresh_token' => wp_unslash( $_GET['refresh_token'] ),
		);
		// phpcs:enable

		$client = $this->get_client();

		try {
			$client->setAccessToken( $access_token );

			if ( WC_Admin_Notices::has_notice( 'bookings_google_calendar_invalid_token_error' ) ) {
				WC_Admin_Notices::remove_notice( 'bookings_google_calendar_invalid_token_error' );
			}
		} catch ( Exception $e ) {
			$this->log(
				$e->getMessage(),
				array(),
				WC_Log_Levels::ERROR
			);

			WC_Admin_Notices::add_custom_notice(
				'bookings_google_calendar_invalid_token_error',
				'<strong>' . esc_html__( 'WooCommerce Bookings:', 'woocommerce-bookings' ) . '</strong> ' . $e->getMessage()
			);
		}

		unset( $access_token['refresh_token'] ); // unset this since we store it in an option.
		$access_token['access_token'] = WC_Bookings_Encryption::instance()->encrypt( $access_token['access_token'] );
		set_transient( 'wc_bookings_gcalendar_access_token', $access_token, self::TOKEN_TRANSIENT_TIME );
		update_option( 'wc_bookings_gcalendar_refresh_token', WC_Bookings_Encryption::instance()->encrypt( $client->getRefreshToken() ) );

		if ( ! empty( $access_token['access_token'] ) ) {
			WC_Admin_Notices::add_custom_notice(
				'bookings_google_calendar_connection',
				'<strong>' . esc_html__( 'WooCommerce Bookings:', 'woocommerce-bookings' ) . '</strong> ' . esc_html__( 'Google Calendar account connected successfully!', 'woocommerce-bookings' )
			);
			$this->log(
				sprintf(
					'Google Oauth successful.'
				)
			);
			update_option( 'wc_bookings_google_calendar_wooconnect_method_connection', true );

			// Update keys before redirect.
			$this->maybe_update_keys_on_auth_redirect();
		} else {
			$this->log(
				sprintf(
					'Google Oauth failed with "%s", "%s"',
					isset( $_GET['error'] ) ? wc_clean( wp_unslash( $_GET['error'] ) ) : '',
					isset( $_GET['error_description'] ) ? wc_clean( wp_unslash( $_GET['error_description'] ) ) : ''
				),
				array(),
				WC_Log_Levels::ERROR
			);
			WC_Admin_Notices::add_custom_notice(
				'bookings_google_calendar_connection',
				'<strong>' . esc_html__( 'WooCommerce Bookings:', 'woocommerce-bookings' ) . '</strong> ' . esc_html__( 'Failed to connect to your Google Calendar account, please try again, if the problem persists, turn on Debug Log option and see what is happening.', 'woocommerce-bookings' )
			);
		}

		$default_redirect_url = add_query_arg( $redirect_args, admin_url( '/edit.php?' ) );

		/**
		 * Filters the redirect URL after the Google Calendar connection.
		 *
		 * Return false to close the popup without redirecting the opener
		 * (useful for SPAs that poll connection status).
		 *
		 * @since 3.2.0
		 *
		 * @param string|false $default_redirect_url Redirect URL, or false to just close popup.
		 */
		$redirect_url = apply_filters( 'woocommerce_bookings_google_calendar_connection_redirect_url', $default_redirect_url );

		// If filter returns false, just close the popup without redirecting.
		if ( false === $redirect_url ) {
			?>
			<!DOCTYPE html>
			<html <?php language_attributes(); ?>>
			<head>
				<meta charset="<?php bloginfo( 'charset' ); ?>">
				<title><?php esc_html_e( 'Connected', 'woocommerce-bookings' ); ?></title>
				<script type="text/javascript">window.close();</script>
			</head>
			<body></body>
			</html>
			<?php
			exit;
		}

		$redirect_url = esc_url_raw( $redirect_url );

		if ( empty( $redirect_url ) ) {
			$redirect_url = $default_redirect_url;
		}

		// Cannot use wp_safe_redirect() here: this response loads in the OAuth popup and must
		// redirect the opener (parent) and close the popup via JavaScript; a server redirect only affects the current window.
		$redirect_url_js = wp_json_encode( $redirect_url );
		?>
		<!DOCTYPE html>
		<html <?php language_attributes(); ?>>
		<head>
			<meta charset="<?php bloginfo( 'charset' ); ?>">
			<meta name="viewport" content="width=device-width, initial-scale=1">
			<title><?php esc_html_e( 'Redirecting…', 'woocommerce-bookings' ); ?></title>
			<script type="text/javascript">
				if ( window.opener && ! window.opener.closed ) {
					window.opener.location.href = <?php echo $redirect_url_js; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() produces safe JS. ?>;
					window.close();
				} else {
					window.location.href = <?php echo $redirect_url_js; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() produces safe JS. ?>;
				}
			</script>
			<noscript>
				<meta http-equiv="refresh" content="0;url=<?php echo esc_url( $redirect_url ); ?>">
				<p><?php esc_html_e( 'Redirecting…', 'woocommerce-bookings' ); ?> <a href="<?php echo esc_url( $redirect_url ); ?>"><?php esc_html_e( 'Click here if you are not redirected.', 'woocommerce-bookings' ); ?></a></p>
			</noscript>
		</head>
		<body></body>
		</html>
		<?php
		exit;
	}

	/**
	 * Update option keys on auth redirect.
	 *
	 * @since 2.0.5
	 */
	private function maybe_update_keys_on_auth_redirect() {
		if ( WC_Bookings_Encryption::instance()->are_custom_bookings_auth_keys_set() ) {
			update_option( 'wc_bookings_auth_key_updated', true );

			// Update the saved custom key to know when changed.
			update_option( 'BOOKINGS_ENCRYPTION_KEY', BOOKINGS_ENCRYPTION_KEY );
			update_option( 'BOOKINGS_ENCRYPTION_SALT', BOOKINGS_ENCRYPTION_SALT );

			// Hide the disconnection notice.
			WC_Admin_Notices::remove_notice( 'bookings_auth_keys_changed_notice' );
		}
	}

	/**
	 * OAuth Logout.
	 *
	 * @return bool
	 */
	protected function oauth_logout_custom() {
		$this->log( 'Leaving the Google Calendar app...' );

		$client  = $this->get_client();
		$success = $client->revokeToken();

		if ( ! $success ) {
			$this->log( 'Failed to revoke access token.', array(), WC_Log_Levels::ERROR, true );
			return false;
		}

		delete_option( 'wc_bookings_gcalendar_refresh_token' );
		delete_transient( 'wc_bookings_gcalendar_sync_token' );
		delete_transient( 'wc_bookings_gcalendar_access_token' );

		$this->log( 'Left the Google Calendar App. successfully' );
		return true;
	}

	/**
	 * Process the oauth redirect.
	 *
	 * @return void
	 */
	public function oauth_redirect_custom() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html( __( 'Permission denied!', 'woocommerce-bookings' ) ) );
		}

		$redirect_args = array(
			'post_type' => 'wc_booking',
			'page'      => 'wc_bookings_settings',
			'tab'       => 'connection',
		);

		$state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';

		// OAuth.
		if ( isset( $_GET['code'] ) ) {
			// Check for the state returned by OAuth service to match the request nonce.
			if ( empty( $state ) || ! wp_verify_nonce( $state, 'wc_bookings_google_calendar_wooconnect' ) ) {
				wp_die( esc_html__( 'Permission denied!', 'woocommerce-bookings' ) );
			}

			update_option( 'wc_bookings_google_calendar_custom_connection', true );
			$code   = sanitize_text_field( wp_unslash( $_GET['code'] ) );
			$client = $this->get_client();
			$client->setClientId( $this->client_id );
			$client->setClientSecret( $this->client_secret );
			$access_token = $client->fetchAccessTokenWithAuthCode( $code );

			if ( empty( $access_token ) ) {
				$redirect_args['wc_gcalendar_oauth'] = 'fail';

				wp_redirect( add_query_arg( $redirect_args, admin_url( '/edit.php?' ) ), 301 ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
				exit;
			}

			unset( $access_token['refresh_token'] ); // unset this since we store it in an option.
			$access_token['access_token'] = WC_Bookings_Encryption::instance()->encrypt( $access_token['access_token'] );
			set_transient( 'wc_bookings_gcalendar_access_token', $access_token, self::TOKEN_TRANSIENT_TIME );
			update_option( 'wc_bookings_gcalendar_refresh_token', WC_Bookings_Encryption::instance()->encrypt( $client->getRefreshToken() ) );
			$redirect_args['wc_gcalendar_oauth'] = 'success';

			// Update keys before redirect.
			$this->maybe_update_keys_on_auth_redirect();

			wp_safe_redirect( add_query_arg( $redirect_args, admin_url( '/edit.php?' ) ), 301 );
			exit;
		}
		if ( isset( $_GET['error'] ) ) {
			$redirect_args['wc_gcalendar_oauth'] = 'fail';

			wp_redirect( add_query_arg( $redirect_args, admin_url( '/edit.php?' ) ), 301 ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
			exit;
		}

		// Logout.
		if ( isset( $_GET['logout'] ) ) {
			if (
				! isset( $_POST[ self::NONCE_NAME ] ) ||
				! wp_verify_nonce( wc_clean( wp_unslash( $_POST[ self::NONCE_NAME ] ) ), self::NONCE_ACTION )
			) {
				wp_die( esc_html( __( 'Invalid request!', 'woocommerce-bookings' ) ) );
			}

			$redirect_args['wc_gcalendar_logout'] = $this->oauth_logout_custom() ? 'success' : 'fail';
			delete_option( 'wc_bookings_google_calendar_custom_connection' );
			wp_redirect( add_query_arg( $redirect_args, admin_url( '/edit.php?' ) ), 301 ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
			exit;
		}

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

	/**
	 * Sync new Booking with Google Calendar.
	 *
	 * @param int $booking_id Booking ID.
	 *
	 * @return void
	 */
	public function sync_new_booking( $booking_id ) {
		if ( $this->is_edited_from_meta_box() || 'wc_booking' !== get_post_type( $booking_id ) ) {
			return;
		}
		$this->sync_booking( $booking_id );
	}

	/**
	 * Check if Google Calendar settings are supplied and we're authenticated.
	 *
	 * @return bool True is calendar is set, false otherwise.
	 */
	public function is_integration_active() {
		$refresh_token = WC_Bookings_Encryption::instance()->decrypt( get_option( 'wc_bookings_gcalendar_refresh_token' ) );

		return ! empty( $refresh_token );
	}

	/**
	 * Sync an event resource with Google Calendar.
	 * https://developers.google.com/google-apps/calendar/v3/reference/events
	 *
	 * @param   int $booking_id Booking ID.
	 * @return  object|boolean Parsed JSON data from the http request or false if error
	 */
	public function get_event_resource( $booking_id ) {
		if ( $booking_id < 0 ) {
			return false;
		}

		$booking = get_wc_booking( $booking_id );
		if ( ! $booking ) {
			return false;
		}
		$event_id = $booking->get_google_calendar_event_id();

		// If the google event is not saved, this should be a fresh try, return false.
		if ( '0' === $event_id ) {
			return false;
		}

		$event = false;

		$this->maybe_init_service();

		try {
			$event = $this->service->events->get( $this->get_calendar_id(), $event_id );
		} catch ( Exception $e ) {
			$this->log( 'Error while getting event for Booking ' . $booking_id . ': ' . $e->getMessage(), array(), WC_Log_Levels::ERROR, true );
		}

		return $event;
	}

	/**
	 * Sync Booking with Google Calendar.
	 *
	 * @param  int $booking_id Booking ID.
	 */
	public function sync_booking( $booking_id ) {
		if ( ! $this->is_integration_active() || 'wc_booking' !== get_post_type( $booking_id ) ) {
			return;
		}

		$this->maybe_init_service();

		// Booking data.
		$booking = get_wc_booking( $booking_id );
		if ( ! $booking ) {
			return;
		}

		$event_id        = $booking->get_google_calendar_event_id();
		$product_id      = $booking->get_product_id();
		$order           = $booking->get_order();
		$product         = wc_get_product( $product_id );
		$booking_product = get_wc_product_booking( $product_id );
		$resource        = $booking_product->get_resource( $booking->get_resource_id() );
		$timezone        = wc_booking_get_timezone_string();
		$description     = '';
		$customer        = $booking->get_customer();
		$status          = $booking->get_status();
		$status_label    = isset( $status ) ? WC_Bookings_Calendar::get_booking_status_label( $status ) : '';

		$booking_data = array(
			__( 'Booking ID', 'woocommerce-bookings' )   => $booking_id,
			__( 'Booking Type', 'woocommerce-bookings' ) => is_object( $resource ) ? $resource->get_title() : '',
			__( 'Persons', 'woocommerce-bookings' )      => $booking->has_persons() ? array_sum( $booking->get_persons() ) : 0,
		);

		foreach ( $booking_data as $key => $value ) {
			if ( empty( $value ) ) {
				continue;
			}

			$description .= sprintf( '%s: %s', rawurldecode( $key ), rawurldecode( $value ) ) . PHP_EOL;
		}

		$edit_booking_url = wc_bookings_get_edit_booking_url( $booking_id );

		// Add read-only message.
		/* translators: %s URL to edit booking */
		$description .= PHP_EOL . sprintf( __( 'NOTE: this event cannot be edited in Google Calendar. If you need to make changes, <a href="%s" target="_blank">please edit this booking in WooCommerce</a>.', 'woocommerce-bookings' ), $edit_booking_url );

		if ( is_a( $order, 'WC_Order' ) ) {
			foreach ( $order->get_items() as $order_item_id => $order_item ) {
				if ( WC_Booking_Data_Store::get_booking_order_item_id( $booking_id ) !== $order_item_id ) {
					continue;
				}
				foreach ( $order_item->get_meta_data() as $order_meta_data ) {
					$the_meta_data = $order_meta_data->get_data();

					if ( is_serialized( $the_meta_data['value'] ) || is_array( $the_meta_data['value'] ) ) {
						continue;
					}

					$description .= sprintf( '%s: %s', html_entity_decode( $the_meta_data['key'], ENT_COMPAT ), html_entity_decode( $the_meta_data['value'], ENT_COMPAT ) ) . PHP_EOL;
				}
			}
		}

		$new_event = false;
		$event     = $this->get_event_resource( $booking_id );
		if ( empty( $event ) ) {
			$new_event = true;
			$event     = new GoogleServiceCalendarEvent();
		}

		// Overwrite event description only if enabled in settings and for new events.
		if ( $new_event || $this->is_event_description_overwritable() ) {
			$event->setDescription( wp_kses_post( $description ) );
		}

		// Set the event data.
		$product_title = $product ? html_entity_decode( $product->get_title(), ENT_COMPAT ) : __( 'Booking', 'woocommerce-bookings' );

		// Prepend booking status.
		if ( ! empty( $status_label ) ) {
			$event->setSummary( wp_kses_post( sprintf( '[%s] %s, %s - #%s', $status_label, $customer->name, $product_title, $booking->get_id() ) ) );
		} else {
			$event->setSummary( wp_kses_post( sprintf( '%s, %s - #%s', $customer->name, $product_title, $booking->get_id() ) ) );
		}

		// Set the event start and end dates.
		$start = new GoogleServiceCalendarEventDateTime();
		$end   = new GoogleServiceCalendarEventDateTime();

		if ( $booking->is_all_day() ) {
			// 1440 min = 24 hours. Bookings includes 'end' in its set of days, where as GCal uses that
			// as the cut off, so we need to add 24 hours to include our final 'end' day.
			// https://developers.google.com/google-apps/calendar/v3/reference/events/insert
			$start->setDate( date( 'Y-m-d', $booking->get_start() ) );
			$end->setDate( date( 'Y-m-d', $booking->get_end() + 1440 ) );
		} else {
			$start->setDateTime( date( 'Y-m-d\TH:i:s', $booking->get_start() ) );
			$start->setTimeZone( $timezone );
			$end->setDateTime( date( 'Y-m-d\TH:i:s', $booking->get_end() ) );
			$end->setTimeZone( $timezone );
		}

		$event->setStart( $start );
		$event->setEnd( $end );

		/**
		 * Update Google event before sync.
		 *
		 * Optional filter to allow third parties to update content of Google event when a booking is created or updated.
		 *
		 * @param GoogleServiceCalendarEvent $event Google event object being added or updated.
		 * @param WC_Booking                 $booking Booking object being synced to Google calendar.
		 */
		$event = apply_filters( 'woocommerce_bookings_gcalendar_sync', $event, $booking );

		try {
			if ( empty( $event->getId() ) ) {
				$event = $this->service->events->insert( $this->get_calendar_id(), $event );
			} else {
				$this->service->events->update( $this->get_calendar_id(), $event->getId(), $event );
			}

			$booking->set_google_calendar_event_id( wc_clean( $event->getId() ) );

			update_post_meta( $booking->get_id(), '_wc_bookings_gcalendar_event_id', $event->getId() );

			if ( $order ) {
				/* translators: 1: booking ID 2: event ID */
				$order->add_order_note( sprintf( esc_html__( 'Booking #%1$d successfully synced to Google Calendar with an Event ID: %2$s', 'woocommerce-bookings' ), $booking_id, $event->getId() ) );
			}
		} catch ( Exception $e ) {
			$error = $e->getMessage();

			// Tweak the error message to be more user friendly.
			if ( strpos( $error, 'message' ) !== false ) {
				$error = sprintf(
					/* translators: 1: settings URL with opening <a> tag 2: closing </a> tag */
					esc_html__(
						'Please verify that your Google Calendar %1$ssettings%2$s are correct',
						'woocommerce-bookings'
					),
					'<a href="' . admin_url( 'edit.php?post_type=wc_booking&page=wc_bookings_settings&tab=connection' ) . '">',
					'</a>'
				);
			}

			$this->log( 'Error while adding/updating Google event: ' . $error, array(), WC_Log_Levels::ERROR, true );

			if ( $order ) {
				/* translators: 1: error 2: booking ID */
				$order_note = sprintf( esc_html__( 'Error while adding/updating Google event, %1$s for booking #%2$d', 'woocommerce-bookings' ), $error, $booking_id );
				if ( $event->getId() ) {
					/* translators: 1: event ID */
					$order_note .= ' ' . sprintf( esc_html__( 'with an Event ID: %s', 'woocommerce-bookings' ), $event->getId() );
				}
				$order->add_order_note( wp_kses_post( $order_note ) );
			}
		}
	}

	/**
	 * Sync Booking with Google Calendar when booking is edited.
	 *
	 * @param  int $booking_id Booking ID.
	 *
	 * @return void
	 */
	public function sync_edited_booking( $booking_id ) {
		if ( ! $this->is_edited_from_meta_box() ) {
			return;
		}
		$this->maybe_sync_booking_from_status( $booking_id );
	}

	/**
	 * Sync Booking with Google Calendar when booking is rescheduled via the
	 * admin reschedule flow. Mirrors sync_edited_booking but skips the
	 * meta-box-nonce gate, which is not present on the reschedule AJAX request.
	 *
	 * @param int $booking_id Booking ID.
	 * @return void
	 */
	public function sync_rescheduled_booking( $booking_id ) {
		$this->maybe_sync_booking_from_status( $booking_id );
	}

	/**
	 * Sync Booking with Google Calendar when booking is untrashed.
	 *
	 * @param  int $booking_id Booking ID.
	 *
	 * @return void
	 */
	public function sync_untrashed_booking( $booking_id ) {
		$this->maybe_sync_booking_from_status( $booking_id );
	}

	/**
	 * Remove/cancel the booking in Google Calendar
	 *
	 * @param  int $booking_id Booking ID.
	 *
	 * @return void
	 */
	public function remove_booking( $booking_id ) {
		if ( 'wc_booking' !== get_post_type( $booking_id ) ) {
			return;
		}

		$this->maybe_init_service();

		$booking = get_wc_booking( $booking_id );
		if ( ! $booking ) {
			return;
		}
		$event_id = $booking->get_google_calendar_event_id();

		if ( $event_id ) {
			try {
				$resp = $this->service->events->delete( $this->get_calendar_id(), $event_id );

				if ( 204 === $resp->getStatusCode() ) {
					$this->log( 'Booking removed successfully!' );

					// Remove event ID.
					update_post_meta( $booking->get_id(), '_wc_bookings_gcalendar_event_id', '' );
				} else {
					$this->log( 'Error while removing the booking #' . $booking->get_id() . ': ' . print_r( $resp, true ), array(), WC_Log_Levels::ERROR, true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
				}
			} catch ( Exception $e ) {
				$this->log( 'Error while deleting event from Google: ' . $e->getMessage(), array(), WC_Log_Levels::ERROR, true );
			}
		}
	}

	/**
	 * Maybe remove / sync booking based on booking status.
	 *
	 * @param int $booking_id Booking ID.
	 *
	 * @return void
	 */
	public function maybe_sync_booking_from_status( $booking_id ) {
		global $wpdb;

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

		if ( 'cancelled' === $status ) {
			$this->remove_booking( $booking_id );
		} else {
			$this->sync_booking( $booking_id );
		}
	}

	/**
	 * Get booking's post statuses considered as paid.
	 *
	 * @return array
	 */
	private function get_booking_is_paid_statuses() {
		/**
		 * Use this filter to add custom booking statuses that should be considered paid.
		 *
		 * @since 1.14.1
		 *
		 * @param array  $statuses All booking statuses considered to be paid.
		 */
		return apply_filters( 'woocommerce_booking_is_paid_statuses', array( 'confirmed', 'paid', 'complete' ) );
	}

	/**
	 * Is edited from post.php's meta box.
	 *
	 * @return bool
	 */
	public function is_edited_from_meta_box() {
		return (
			! empty( $_POST['wc_bookings_details_meta_box_nonce'] )
			&&
			wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wc_bookings_details_meta_box_nonce'] ) ), 'wc_bookings_details_meta_box' )
		);
	}

	/**
	 * Maybe delete Global Availability from Google.
	 *
	 * @param WC_Global_Availability $availability Availability to delete.
	 */
	public function delete_global_availability( WC_Global_Availability $availability ) {
		/**
		 * Prevents deletion of an event from Google calendar whenever the
		 * corresponding store availability is deleted.
		 */
		if ( ! $this->is_event_deletion_preferred() ) {
			// Get event IDs of all deleted global store availabilities.
			$deleted_availabilities = get_option( 'woocommerce_bookings_deleted_global_availability', array() );
			if ( ! isset( $deleted_availabilities[ $availability->get_gcal_event_id() ] ) ) {

				/**
				 * We store the event IDs of all the global store availabilities
				 * that are deleted from WC but don't want it to be deleted from
				 * Google Calendar. We later refer to this to prevent re-importing
				 * deleted events from GC again.
				 */
				$range_type = $availability->get_range_type();

				$event_end_date = '';
				if ( 'custom' === $range_type ) {
					$event_end_date = $availability->get_to_range();
				} elseif ( 'custom:daterange' === $range_type ) {
					$event_end_date = $availability->get_to_date();
				}

				/**
				 * This option can grow indefinitely. This is why we store
				 * the event IDs along with the event end date, so that the
				 * Action Scheduler can clean up old events.
				 */
				$deleted_availabilities[ $availability->get_gcal_event_id() ] = $event_end_date;
				update_option( 'woocommerce_bookings_deleted_global_availability', $deleted_availabilities, 'no' );
			}
			return;
		}

		$this->maybe_init_service();

		if ( $availability->get_gcal_event_id() ) {
			try {
				$this->service->events->delete( $this->get_calendar_id(), $availability->get_gcal_event_id() );
			} catch ( Exception $e ) {
				$this->log( 'Error while deleting event from Google: ' . $e->getMessage(), array(), WC_Log_Levels::ERROR, true );
			}
		}
	}

	/**
	 * Sync Global Availability to Google.
	 *
	 * @param WC_Global_Availability $availability Global Availability object.
	 */
	public function sync_global_availability( WC_Global_Availability $availability ) {
		if ( ! $this->is_integration_active() ) {
			return;
		}

		if ( ! $availability->get_changes() ) {
			// nothing changed don't waste time syncing.
			return;
		}

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

		$this->maybe_init_service();

		if ( $availability->get_gcal_event_id() ) {
			try {
				$event     = $this->service->events->get( $this->get_calendar_id(), $availability->get_gcal_event_id() );
				$supported = $this->update_event_from_global_availability( $event, $availability );
				if ( $supported ) {
					$this->service->events->update( $this->get_calendar_id(), $event->getId(), $event );
				}
			} catch ( Exception $e ) {
				$this->log( 'Error while syncing global availability to Google: ' . $e->getMessage(), array(), WC_Log_Levels::ERROR, true );
			}
		}
	}

	/**
	 * Sanitize a recurring rule to make sure the date + time formats match up.
	 *
	 * @param string                     $rrule Recurring Rule.
	 * @param GoogleServiceCalendarEvent $event Google calendar event object.
	 *
	 * @return string
	 */
	private function maybe_sanitize_rrule( $rrule, GoogleServiceCalendarEvent $event ) {

		// If we have only a start date then make sure the UNTIL also only has a date.
		if ( ! $event->getStart()->getDateTime() && $event->getStart()->getDate() ) {
			$rrule = preg_replace( '/(UNTIL=\d{8})T\d{6}[^;]*/', '$1', $rrule );
		}

		return $rrule;
	}

	/**
	 * Update global availability object with data from google event object.
	 *
	 * @param WC_Global_Availability     $availability WooCommerce Global Availability object.
	 * @param GoogleServiceCalendarEvent $event Google calendar event object.
	 *
	 * @return bool
	 */
	private function update_global_availability_from_event( WC_Global_Availability $availability, GoogleServiceCalendarEvent $event ) {
		$availability->set_gcal_event_id( $event->getId() )
			->set_title( $event->getSummary() )
			->set_bookable( 'no' )
			->set_priority( 10 )
			->set_ordering( 0 );

		// TODO: check timezones.
		if ( $event->getRecurrence() ) {
			$availability->set_range_type( 'rrule' );
			$availability->set_rrule( $this->maybe_sanitize_rrule( join( "\n", $event->getRecurrence() ), $event ) );
			if ( $event->getStart()->getDateTime() ) {
				$availability->set_from_range( $event->getStart()->getDateTime() );
				$availability->set_to_range( $event->getEnd()->getDateTime() );
			} else {
				$availability->set_from_range( $event->getStart()->getDate() );
				$availability->set_to_range( $event->getEnd()->getDate() );
			}
		} elseif ( $event->getStart()->getDateTime() ) {

			$start_date = new WC_DateTime( $event->getStart()->getDateTime() );
			$end_date   = new WC_DateTime( $event->getEnd()->getDateTime() );

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

		} else {

			$start_date = new WC_DateTime( $event->getStart()->getDate() );
			$end_date   = new WC_DateTime( $event->getEnd()->getDate() );

			try {
				// Our date ranges are inclusive, Google's are not.
				$end_date->sub( new DateInterval( 'P1D' ) );
			} catch ( Exception $e ) {
				$this->log( $e->getMessage(), array(), WC_Log_Levels::ERROR, true );
				// Should never happen.
			}

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

		}
		return true;
	}

	/**
	 * Update google event object with data from global availability object.
	 *
	 * @param GoogleServiceCalendarEvent $event Google calendar event object.
	 * @param WC_Global_Availability     $availability WooCommerce Global Availability object.
	 *
	 * @return bool
	 */
	private function update_event_from_global_availability( GoogleServiceCalendarEvent $event, WC_Global_Availability $availability ) {
		$event->setSummary( $availability->get_title() );
		$timezone        = wc_booking_get_timezone_string();
		$start           = new GoogleServiceCalendarEventDateTime();
		$end             = new GoogleServiceCalendarEventDateTime();
		$start_date_time = new WC_DateTime();
		$end_date_time   = new WC_DateTime();

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

				$end_date_time = new WC_DateTime( $availability->get_to_date() . ' ' . $availability->get_to_range() );
				$end->setDateTime( $end_date_time->format( 'Y-m-d\TH:i:s' ) );
				$end->setTimeZone( $timezone );
				$event->setEnd( $end );
				break;
			case 'custom':
				$start_date_time = new WC_DateTime( $availability->get_from_range() );
				$start->setDate( $start_date_time->format( 'Y-m-d' ) );
				$event->setStart( $start );

				$end_date_time = new WC_DateTime( $availability->get_to_range() );
				$end_date_time->add( new DateInterval( 'P1D' ) );
				$end->setDate( $end_date_time->format( 'Y-m-d' ) );
				$event->setEnd( $end );
				break;
			case 'months':
				$start_date_time->setDate(
					date( 'Y' ),
					$availability->get_from_range(),
					1
				);

				$start->setDate( $start_date_time->format( 'Y-m-d' ) );
				$event->setStart( $start );

				$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' ) );

				$end->setDate( $end_date_time->format( 'Y-m-d' ) );
				$event->setEnd( $end );

				$event->setRecurrence(
					array(
						'RRULE:FREQ=YEARLY',
					)
				);

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

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

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

				$start->setDate( $start_date_time->format( 'Y-m-d' ) );
				$event->setStart( $start );

				$end->setDate( $end_date_time->format( 'Y-m-d' ) );
				$event->setEnd( $end );

				$event->setRecurrence(
					array(
						$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 ] );
				$start->setDate( $start_date_time->format( 'Y-m-d' ) );
				$event->setStart( $start );

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

				$end->setDate( $end_date_time->format( 'Y-m-d' ) );
				$event->setEnd( $end );

				$event->setRecurrence(
					array(
						'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':
				list( , $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';
				}

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

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

				$start->setDateTime( $start_date_time->format( 'Y-m-d\TH:i:s' ) );
				$start->setTimeZone( $timezone );
				$event->setStart( $start );

				$end->setDateTime( $end_date_time->format( 'Y-m-d\TH:i:s' ) );
				$end->setTimeZone( $timezone );
				$event->setEnd( $end );

				$event->setRecurrence(
					array(
						$rrule,
					)
				);
				break;

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

	/**
	 * Renew access token with refresh token. Must pass through api.woocommerce.com/integrations middleware.
	 *
	 * @param string       $refresh_token Refresh Token.
	 * @param GoogleClient $client Google Client Object.
	 *
	 * @return array
	 */
	private function renew_access_token( $refresh_token, $client ) {

		if ( get_option( 'wc_bookings_google_calendar_custom_connection' ) ) {
			$client->setClientId( $this->client_id );
			$client->setClientSecret( $this->client_secret );
			return $client->fetchAccessTokenWithRefreshToken( $refresh_token );
		}

		$response     = wp_remote_post(
			self::CONNECT_WOOCOMMERCE_URL . '/renew/google',
			array(
				'body' => array( 'refresh_token' => $refresh_token ),
			)
		);
		$access_token = json_decode( wp_remote_retrieve_body( $response ), true );
		return $access_token;
	}

	/**
	 * Get google login url from api.woocommerce.com/integrations.
	 *
	 * @return string
	 */
	private function get_google_auth_url() {
		$client = $this->get_client();

		if ( $client->getClientId() ) {
			return $client->createAuthUrl();
		}

		// nosemgrep:audit.php.wp.security.xss.query-arg
		return add_query_arg(
			array(
				'redirect' => urlencode(
					add_query_arg(
						array( 'nonce' => wp_create_nonce( 'wc_bookings_google_calendar_wooconnect' ) ),
						WC()->api_request_url( 'wc_bookings_google_calendar_wooconnect' )
					)
				),
			),
			self::CONNECT_WOOCOMMERCE_URL . '/login/google'
		);
	}
}
