<?php

namespace SugarCalendarBookings\Integrations\Outlook;

use SugarCalendarBookings\Appointments\AppointmentModel;
use SugarCalendarBookings\Integrations\AbstractOAuthIntegration;
use SugarCalendarBookings\Integrations\CalendarConflictProviderInterface;
use SugarCalendarBookings\Integrations\CalendarEventProviderInterface;
use SugarCalendarBookings\Integrations\CalendarEvents\CancellableCalendarEventProviderInterface;
use SugarCalendarBookings\Integrations\ExternalBusyTimes\ExternalBusyTimeService;
use SugarCalendarBookings\Integrations\IntegrationCapabilityRegistry;
use SugarCalendarBookings\Integrations\IntegrationRegistry;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionInterface;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionModel;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthRelayClient;
use SugarCalendarBookings\Integrations\Outlook\Admin\OutlookSettingsRenderer;
use SugarCalendarBookings\Integrations\Webhooks\IncomingWebhookHandler;
use SugarCalendarBookings\Plugin;
use SugarCalendarBookings\Support\Bootable;
use SugarCalendarBookings\Support\Helpers\Date;
use SugarCalendarBookings\Support\Helpers\Helpers;
use WP_Error;

/**
 * Outlook Calendar integration.
 *
 * Provides Outlook (Microsoft Graph) Calendar integration for the Integrations
 * settings page, mirrors appointments as calendar events on connected accounts,
 * and supplies busy-time data for the external conflict checker.
 *
 * Outlook is NOT an online-location provider — calendar sync is per-user, not
 * per-online-location.
 *
 * @since 1.5.0
 */
class OutlookIntegration extends AbstractOAuthIntegration implements CalendarEventProviderInterface, CancellableCalendarEventProviderInterface, CalendarConflictProviderInterface, Bootable {

	/**
	 * Provider slug.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	private const PROVIDER_SLUG = 'outlook';

	/**
	 * Outlook actions client.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookActionsClient
	 */
	private OutlookActionsClient $client;

	/**
	 * Graph event payload builder.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookEventPayloadBuilder
	 */
	private OutlookEventPayloadBuilder $payload_builder;

	/**
	 * Busy-time service.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookBusyTimeService
	 */
	private OutlookBusyTimeService $busy_times;

	/**
	 * Per-connection settings repository.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookCalendarSettingsRepository
	 */
	private OutlookCalendarSettingsRepository $settings_repository;

	/**
	 * Smart-tag picker / meeting templates.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookSmartTagPicker
	 */
	private OutlookSmartTagPicker $smart_tags;

	/**
	 * Admin settings renderer.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookSettingsRenderer
	 */
	private OutlookSettingsRenderer $renderer;

	/**
	 * Constructor.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthRelayClient                  $oauth_relay_client  OAuth relay client.
	 * @param OutlookActionsClient              $client              Outlook actions client.
	 * @param OutlookEventPayloadBuilder        $payload_builder     Graph event payload builder.
	 * @param OutlookBusyTimeService            $busy_times          Busy-time service.
	 * @param OutlookCalendarSettingsRepository $settings_repository Per-connection settings repository.
	 * @param OutlookSmartTagPicker             $smart_tags          Smart-tag picker / templates.
	 * @param OutlookSettingsRenderer           $renderer            Admin settings renderer.
	 */
	public function __construct(
		OAuthRelayClient $oauth_relay_client,
		OutlookActionsClient $client,
		OutlookEventPayloadBuilder $payload_builder,
		OutlookBusyTimeService $busy_times,
		OutlookCalendarSettingsRepository $settings_repository,
		OutlookSmartTagPicker $smart_tags,
		OutlookSettingsRenderer $renderer
	) {

		parent::__construct( $oauth_relay_client );

		$this->client              = $client;
		$this->payload_builder     = $payload_builder;
		$this->busy_times          = $busy_times;
		$this->settings_repository = $settings_repository;
		$this->smart_tags          = $smart_tags;
		$this->renderer            = $renderer;
	}

	/**
	 * Initialize the integration.
	 *
	 * Registers with the settings UI registry and the capability registry.
	 * Does NOT register with OnlineLocationProviderFactory — Outlook is not a
	 * location type.
	 *
	 * @since 1.5.0
	 */
	public function init() {

		Plugin::get( IntegrationRegistry::class )->register( $this );
		Plugin::get( IntegrationCapabilityRegistry::class )->register( $this );
	}

	/**
	 * Get the integration slug.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_slug(): string {

		return self::PROVIDER_SLUG;
	}

	/**
	 * Get the integration display name.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_name(): string {

		return esc_html__( 'Outlook Calendar', 'sugar-calendar-bookings-scheduling-appointments-lite' );
	}

	/**
	 * Get the label used in the "Connect with {label}" button.
	 *
	 * Outlook Calendar is connected via a Microsoft account, so the button
	 * reads "Connect with Microsoft" rather than "Connect with Outlook Calendar".
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_connect_label(): string {

		return esc_html__( 'Microsoft', 'sugar-calendar-bookings-scheduling-appointments-lite' );
	}

	/**
	 * Get the icon URL shown inside the "Connect with Microsoft" button.
	 *
	 * Uses the Microsoft brand mark (4-square logo) rather than the
	 * integration's own Outlook Calendar icon, matching the Figma design.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_connect_icon_url(): string {

		return SCBOOKINGS_PLUGIN_URL . 'assets/images/icons/integrations/microsoft.svg';
	}

	/**
	 * Get the URL to the Outlook icon.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_icon_url(): string {

		return SCBOOKINGS_PLUGIN_URL . 'assets/images/icons/integrations/outlook.svg';
	}

	/**
	 * Get the integration description.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_description(): string {

		return sprintf(
			/* translators: %1$s - opening link tag; %2$s - closing link tag. */
			__( 'Keep appointments in sync between Sugar Calendar and Outlook, both directions, with conflict checks built in. %1$sLearn more%2$s.', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
			'<a href="' . esc_url(
				Helpers::get_utm_url(
					'https://sugarcalendar.com/docs/bookings/outlook-integration/',
					[
						'medium'  => 'settings-integrations-outlook',
						'content' => 'Outlook documentation',
					]
				)
			) . '" target="_blank" rel="noopener noreferrer">',
			'</a>'
		);
	}

	/**
	 * Check if the integration is available for use.
	 *
	 * @since 1.5.0
	 *
	 * @return bool
	 */
	public function is_available(): bool {

		return $this->is_enabled() && $this->has_active_connection();
	}

	/**
	 * Wrap an OAuth connection model in an Outlook-specific connection.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $model The connection model.
	 *
	 * @return OAuthConnectionInterface
	 */
	protected function wrap_connection( OAuthConnectionModel $model ): OAuthConnectionInterface {

		return new OutlookOAuthConnection( $model );
	}

	/**
	 * Suppress the abstract's automatic Connected Employee Accounts placement.
	 *
	 * Outlook renders the section inline via render_provider_settings() so it
	 * appears below Monitor Employee Appointments. The default early call from
	 * AbstractOAuthIntegration::render_content() is intentionally a no-op here.
	 *
	 * Actual rendering is delegated to render_outlook_employee_connections(),
	 * which Lite resolves to the parent upsell and Pro overrides to render the
	 * empty state or the list of employee connections.
	 *
	 * @since 1.5.0
	 */
	protected function render_employee_connections_section(): void {
	}

	/**
	 * Render the Connected Employee Accounts section in the Outlook tab.
	 *
	 * Lite shows the Pro upsell without the abstract's leading divider, so
	 * the section sits flush below Monitor Employee Appointments. Pro
	 * overrides this method to render the empty state or the connections list.
	 *
	 * @since 1.5.0
	 */
	protected function render_outlook_employee_connections(): void {

		$this->renderer->render_employee_connections_upsell();
	}

	/**
	 * Render the Monitor Employee Appointments section.
	 *
	 * Lite renders the Pro upsell. Pro overrides this to render the actual
	 * radio + employee multi-select. Defining the seam in Lite prevents a
	 * fatal "Call to undefined method" on Lite-only installs.
	 *
	 * @since 1.5.0
	 */
	protected function render_monitor_employee_appointments_section(): void {

		$this->renderer->render_monitor_employee_appointments_upsell();
	}

	/**
	 * Render the "Create appointments on" dropdown(s) above the Your Accounts block.
	 *
	 * Surfaces the destination calendar selector first — users typically pick
	 * the calendar before reviewing the connected account list below.
	 *
	 * @since 1.5.0
	 */
	protected function render_pre_accounts_settings(): void {

		foreach ( $this->get_admin_connections() as $connection ) {
			$this->renderer->render_connection_write_calendar( $connection );
		}
	}

	/**
	 * Render the per-connection conflict-calendars panel inside the account card.
	 *
	 * Attaches a gray-background panel below the account info row containing
	 * the "Calendars to check for conflicts" multi-select for this connection.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection Connection whose card is being rendered.
	 */
	protected function render_account_item_extras( OAuthConnectionModel $connection ): void {

		$this->renderer->render_account_extras( $connection );
	}

	/**
	 * Render the Outlook-specific provider settings.
	 *
	 * Renders, in order:
	 *  - Monitor Employee Appointments (upsell in Lite).
	 *  - Connected Employee Accounts (upsell in Lite; list/empty state in Pro).
	 *  - Meeting Title input with smart-tag picker.
	 *  - Meeting Agenda textarea with smart-tag picker.
	 *
	 * The "Create appointments on" dropdown is rendered above the accounts
	 * list via {@see self::render_pre_accounts_settings()} and the per-account
	 * "Calendars to check for conflicts" panel is attached inside each
	 * account card via {@see self::render_account_item_extras()}.
	 *
	 * @since 1.5.0
	 */
	protected function render_provider_settings(): void {

		?>
		<hr class="sugar-calendar-bookings-setting-row-divider" />
		<?php

		$this->render_monitor_employee_appointments_section();

		$this->render_outlook_employee_connections();

		?>
		<hr class="sugar-calendar-bookings-setting-row-divider" />
		<?php

		$this->renderer->render_meeting_inputs();
	}

	/**
	 * Get the post data processing rules for Outlook settings.
	 *
	 * @since 1.5.0
	 *
	 * @return array
	 */
	public function get_post_data_processing_rules() {

		$rules = parent::get_post_data_processing_rules();

		$rules['integrations.outlook.monitor_employee_appointments'] = 'string|in:none,all,custom';
		$rules['integrations.outlook.monitor_employee_ids']          = 'array';
		$rules['integrations.outlook.monitor_employee_ids.*']        = 'id';
		$rules['integrations.outlook.meeting_title']                 = 'string|max:255';
		$rules['integrations.outlook.meeting_agenda']                = 'string';

		// Per-connection calendar settings — InputProcessor drops anything
		// missing from rules, so the renderer's bare-mode form fields
		// (connections.{id}.write_calendar_id, connections.{id}.conflict_calendar_ids[])
		// would silently vanish without these wildcards.
		//
		// The write calendar is required: a blank selection makes push_event()
		// fail with `outlook:no_write_calendar`, so appointments would silently
		// stop syncing. The renderer pre-selects the account's primary calendar,
		// so this only rejects the save in the degenerate no-calendars case.
		$rules['connections.*.write_calendar_id']       = 'required|string|max:255';
		$rules['connections.*.conflict_calendar_ids']   = 'array';
		$rules['connections.*.conflict_calendar_ids.*'] = 'string|max:255';

		return $rules;
	}

	/**
	 * Persist per-connection calendar settings posted from the integration page.
	 *
	 * The "Create appointments on" select and the "Calendars to check for
	 * conflicts" multi-select live on a different storage layer (the
	 * oauth_connections.settings JSON column), so we have to intercept them
	 * here before Options::update() flushes the rest of $post_data into the
	 * plugin options blob. Unsetting `connections` after persisting keeps the
	 * options table from accumulating stale per-row state.
	 *
	 * @since 1.5.0
	 *
	 * @param array $post_data Sanitized POST data, by reference.
	 */
	public function handle_post_data( array &$post_data ): void {

		if ( empty( $post_data['connections'] ) || ! is_array( $post_data['connections'] ) ) {
			return;
		}

		$submitted = $post_data['connections'];

		unset( $post_data['connections'] );

		$writable = $this->get_writable_connections();

		foreach ( $submitted as $connection_id => $values ) {

			$connection = $writable[ (int) $connection_id ] ?? null;

			if ( $connection === null || ! is_array( $values ) ) {
				continue;
			}

			$this->save_calendar_settings( $connection, $values );
		}
	}

	/**
	 * Connections the current user may write on this page, keyed by id.
	 *
	 * Admins write the global account; employees (Pro) write only their own row.
	 *
	 * @since 1.5.0
	 *
	 * @return array<int, OAuthConnectionModel>
	 */
	private function get_writable_connections(): array {

		$writable = [];

		foreach ( $this->get_admin_connections() as $connection ) {
			$writable[ $connection->id ] = $connection;
		}

		return $writable;
	}

	/**
	 * Persist the posted write/conflict calendar selection for one connection
	 * and reconcile everything that depends on it.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection Connection being saved.
	 * @param array                $values     Posted values for this connection.
	 */
	private function save_calendar_settings( OAuthConnectionModel $connection, array $values ): void {

		$settings         = $this->settings_repository->load( $connection );
		$old_conflict_ids = $this->sanitize_calendar_ids( $settings['conflict_calendar_ids'] ?? [] );
		$old_write_id     = isset( $settings['write_calendar_id'] ) ? (string) $settings['write_calendar_id'] : '';

		$settings['write_calendar_id']     = isset( $values['write_calendar_id'] ) ? (string) $values['write_calendar_id'] : '';
		$settings['conflict_calendar_ids'] = $this->sanitize_calendar_ids( $values['conflict_calendar_ids'] ?? [] );

		$this->settings_repository->save( $connection, $settings );

		$this->reconcile_calendars(
			$connection,
			array_diff( $settings['conflict_calendar_ids'], $old_conflict_ids ),
			array_diff( $old_conflict_ids, $settings['conflict_calendar_ids'] ),
			$old_write_id !== $settings['write_calendar_id']
		);
	}

	/**
	 * React to a changed calendar selection.
	 *
	 * Re-syncs the per-calendar Graph subscriptions, backfills busy times for
	 * newly watched conflict calendars, and drops busy times for conflict
	 * calendars no longer watched. A no-op when nothing changed.
	 *
	 * Subscription sync covers the full watched set (conflict calendars + the
	 * write calendar), so a changed write calendar re-syncs subscriptions too —
	 * but never triggers busy-time backfill, because the write calendar is
	 * subscribed only so appointment-event edits sync back, not as a conflict
	 * source.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection    Connection whose selection changed.
	 * @param string[]             $added_ids     Conflict calendar ids newly added.
	 * @param string[]             $removed_ids   Conflict calendar ids removed.
	 * @param bool                 $write_changed Whether the write calendar changed.
	 */
	private function reconcile_calendars( OAuthConnectionModel $connection, array $added_ids, array $removed_ids, bool $write_changed ): void {

		if ( empty( $added_ids ) && empty( $removed_ids ) && ! $write_changed ) {
			return;
		}

		// Each watched calendar owns its own subscription so notifications carry
		// a known source calendar. The manager reconciles the saved selection
		// (conflict calendars + write calendar) against existing subscriptions
		// and self-heals any drift.
		Plugin::get( OutlookSubscriptionManager::class )->sync(
			$connection,
			IncomingWebhookHandler::get_webhook_url( self::PROVIDER_SLUG )
		);

		// Backfill busy times for newly added conflict calendars so existing
		// events block slots immediately, without waiting for a webhook per event.
		if ( ! empty( $added_ids ) ) {
			$this->backfill_busy_times( $connection );
		}

		// Drop stored busy times for deselected conflict calendars so they stop
		// blocking booking slots right away.
		if ( ! empty( $removed_ids ) ) {
			$service = Plugin::get( ExternalBusyTimeService::class );

			foreach ( $removed_ids as $removed_id ) {
				$service->delete_by_calendar( self::PROVIDER_SLUG, $removed_id );
			}
		}
	}

	/**
	 * Normalize a posted or stored list of calendar ids to a clean string array.
	 *
	 * @since 1.5.0
	 *
	 * @param mixed $ids Raw calendar id list.
	 *
	 * @return string[]
	 */
	private function sanitize_calendar_ids( $ids ): array {

		if ( ! is_array( $ids ) ) {
			return [];
		}

		return array_values( array_filter( array_map( 'strval', $ids ), 'strlen' ) );
	}

	/**
	 * Get the provider slug identifier.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_provider_slug(): string {

		return self::PROVIDER_SLUG;
	}

	/**
	 * Get the provider display name.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_display_name(): string {

		return $this->get_name();
	}

	/**
	 * Meta keys this provider writes to each entry in appointment meta
	 * `external_calendar_events`.
	 *
	 * @since 1.5.0
	 *
	 * @return string[]
	 */
	public function get_event_link_keys(): array {

		return [ 'event_id', 'event_url', 'calendar_id', 'connection_id', 'pushed_at' ];
	}

	/**
	 * Push a new calendar event for an appointment on a specific connection.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel     $appointment Appointment to mirror.
	 * @param OAuthConnectionModel $connection  Target OAuth connection.
	 *
	 * @return array|WP_Error Event link metadata on success, WP_Error on failure.
	 */
	public function push_event( AppointmentModel $appointment, OAuthConnectionModel $connection ) {

		$settings          = $this->settings_repository->load( $connection );
		$write_calendar_id = isset( $settings['write_calendar_id'] ) ? (string) $settings['write_calendar_id'] : '';

		if ( $write_calendar_id === '' ) {
			return new WP_Error(
				'outlook:no_write_calendar',
				esc_html__( 'No write calendar selected for this Outlook connection.', 'sugar-calendar-bookings-scheduling-appointments-lite' )
			);
		}

		$wrapped = $this->wrap_connection( $connection );
		$payload = $this->payload_builder->build_create_payload( $appointment, $connection );

		$response = $this->client->create_event( $wrapped, $write_calendar_id, $payload );

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

		return [
			'connection_id' => (int) $connection->id,
			'provider'      => self::PROVIDER_SLUG,
			'calendar_id'   => $write_calendar_id,
			'event_id'      => isset( $response['id'] ) ? (string) $response['id'] : '',
			'event_url'     => isset( $response['webLink'] ) ? (string) $response['webLink'] : '',
			'pushed_at'     => gmdate( 'Y-m-d\TH:i:s\Z' ),
		];
	}

	/**
	 * Update an existing calendar event after an appointment change.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel     $appointment   Updated appointment.
	 * @param OAuthConnectionModel $connection    Target OAuth connection.
	 * @param string               $event_id      Existing provider event ID.
	 * @param array                $existing_link Previously-stored event link entry.
	 *
	 * @return array|WP_Error Updated link metadata or WP_Error on failure.
	 */
	public function update_event( AppointmentModel $appointment, OAuthConnectionModel $connection, string $event_id, array $existing_link ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$wrapped = $this->wrap_connection( $connection );
		$payload = $this->payload_builder->build_update_payload( $appointment, $connection );

		$response = $this->client->update_event( $wrapped, $event_id, $payload );

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

		$calendar_id = isset( $existing_link['calendar_id'] ) ? (string) $existing_link['calendar_id'] : '';

		return [
			'connection_id' => isset( $existing_link['connection_id'] ) ? (int) $existing_link['connection_id'] : (int) $connection->id,
			'provider'      => self::PROVIDER_SLUG,
			'calendar_id'   => $calendar_id,
			'event_id'      => isset( $response['id'] ) ? (string) $response['id'] : $event_id,
			'event_url'     => isset( $response['webLink'] ) ? (string) $response['webLink'] : ( $existing_link['event_url'] ?? '' ),
			'pushed_at'     => gmdate( 'Y-m-d\TH:i:s\Z' ),
		];
	}

	/**
	 * Cancel the Outlook calendar event and notify attendees.
	 *
	 * Sends a Graph POST /events/{id}/cancel so all attendees receive a
	 * cancellation email and the event is marked cancelled in their calendars.
	 * The event is NOT deleted — it remains in the organiser's calendar until
	 * the appointment is hard-deleted via delete_event().
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel     $appointment   Cancelled appointment.
	 * @param OAuthConnectionModel $connection    Target OAuth connection.
	 * @param string               $event_id      Provider event ID to cancel.
	 * @param array                $existing_link Previously-stored event link entry.
	 *
	 * @return true|WP_Error True on success or error.
	 */
	public function cancel_event( AppointmentModel $appointment, OAuthConnectionModel $connection, string $event_id, array $existing_link ) {

		unset( $appointment, $existing_link );

		$wrapped = $this->wrap_connection( $connection );

		return $this->client->cancel_event( $wrapped, $event_id );
	}

	public function delete_event( AppointmentModel $appointment, OAuthConnectionModel $connection, string $event_id, array $existing_link ) {

		unset( $appointment );

		$calendar_id = $this->resolve_calendar_id( $existing_link );

		if ( $calendar_id === '' ) {
			return $this->no_calendar_id_error( $event_id );
		}

		$wrapped = $this->wrap_connection( $connection );

		return $this->client->delete_event( $wrapped, $event_id, $calendar_id );
	}

	/**
	 * Backfill busy times for a connection when conflict calendars are newly added.
	 *
	 * Mirrors the initial-connect fetch in AbstractOAuthCallbackHandler::maybe_fetch_busy_times()
	 * so that existing Outlook events are visible to the conflict checker immediately
	 * after the user saves their calendar selection, without requiring a reconnect.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection Connection to backfill.
	 */
	private function backfill_busy_times( OAuthConnectionModel $connection ): void {

		$now  = Date::create();
		$from = $now->format( 'Y-m-d H:i:s' );
		$to   = $now->modify( '+90 days' )->format( 'Y-m-d H:i:s' );

		$grouped = $this->busy_times->fetch_by_calendars(
			$connection,
			$this->wrap_connection( $connection ),
			$from,
			$to
		);

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

		$service = Plugin::get( ExternalBusyTimeService::class );

		foreach ( $grouped as $calendar_id => $events ) {
			$service->store_busy_times(
				$connection,
				self::PROVIDER_SLUG,
				$calendar_id,
				$events
			);
		}
	}

	/**
	 * Resolve the calendar ID an event was originally created in.
	 *
	 * Reads the value persisted at create time. Returns an empty string for
	 * legacy links that pre-date the calendar_id field — callers must skip
	 * the API call in that case, because the relay rejects requests without
	 * a calendar_id and consumer Graph accounts can't resolve unscoped
	 * `/me/events/{id}` against non-primary calendars.
	 *
	 * @since 1.5.0
	 *
	 * @param array $existing_link Previously-stored event link entry.
	 *
	 * @return string
	 */
	private function resolve_calendar_id( array $existing_link ): string {

		if ( isset( $existing_link['calendar_id'] ) && $existing_link['calendar_id'] !== '' ) {
			return (string) $existing_link['calendar_id'];
		}

		if (
			isset( $existing_link['extra']['calendar_id'] )
			&& $existing_link['extra']['calendar_id'] !== ''
		) {
			return (string) $existing_link['extra']['calendar_id'];
		}

		return '';
	}

	/**
	 * Build a WP_Error for the missing-calendar-id case.
	 *
	 * Logs the orphaned event so the admin can clean it up manually, then
	 * returns a WP_Error that the dispatcher collects without aborting the
	 * rest of the cancellation/deletion flow.
	 *
	 * @since 1.5.0
	 *
	 * @param string $event_id Provider event ID we could not delete.
	 *
	 * @return WP_Error
	 */
	private function no_calendar_id_error( string $event_id ): WP_Error {

		unset( $event_id );

		return new WP_Error(
			'outlook:missing_calendar_id',
			esc_html__( 'Cannot delete Outlook event: missing calendar ID for legacy link.', 'sugar-calendar-bookings-scheduling-appointments-lite' )
		);
	}

	/**
	 * Return the OAuth connections that should receive an event for this appointment.
	 *
	 * Lite returns just the admin's global connection (if any). Pro overrides
	 * this to add per-employee connections and Monitor Employee fan-out.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment Appointment to resolve connections for.
	 *
	 * @return OAuthConnectionModel[]
	 */
	public function get_deliverable_connections( AppointmentModel $appointment ): array {

		unset( $appointment );

		// Scope to the global admin so we never deliver to an arbitrary
		// user's active connection that happens to come first in the table.
		$global_user_ids = $this->get_global_account_user_ids();

		if ( empty( $global_user_ids ) ) {
			return [];
		}

		$model = OAuthConnectionModel::query()
			->where( 'provider', '=', self::PROVIDER_SLUG, '%s' )
			->where( 'status', '=', 'active', '%s' )
			->where( 'user_id', 'IN', $global_user_ids, '%d' )
			->first();

		return $model ? [ $model ] : [];
	}

	/**
	 * Fetch busy times from Outlook for a given connection.
	 *
	 * Issues a single getSchedule call covering the connection's configured
	 * conflict calendars and the user's primary mailbox, then filters the
	 * response to entries from the selected calendars.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection OAuth connection.
	 * @param string               $from       Start datetime (Y-m-d H:i:s).
	 * @param string               $to         End datetime (Y-m-d H:i:s).
	 *
	 * @return array Array of busy time entries.
	 */
	public function fetch_busy_times( OAuthConnectionModel $connection, string $from, string $to ): array {

		return $this->busy_times->fetch( $connection, $this->wrap_connection( $connection ), $from, $to );
	}

	/**
	 * Get the calendar ID for the connection's write calendar.
	 *
	 * Multi-calendar fan-out lives inside fetch_busy_times(). This method
	 * returns a single representative ID used to key busy-time rows in the
	 * external_busy_times table.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection OAuth connection.
	 *
	 * @return string Calendar identifier, or empty string if none configured.
	 */
	public function get_calendar_id( OAuthConnectionModel $connection ): string {

		$settings = $this->settings_repository->load( $connection );

		return isset( $settings['write_calendar_id'] ) ? (string) $settings['write_calendar_id'] : '';
	}
}
