<?php

namespace SugarCalendarBookings\Integrations\Outlook;

use DateTimeImmutable;
use DateTimeZone;
use Exception;
use SugarCalendarBookings\Appointments\AppointmentDTO;
use SugarCalendarBookings\Appointments\AppointmentModel;
use SugarCalendarBookings\Appointments\AppointmentService;
use SugarCalendarBookings\Integrations\ExternalBusyTimes\ExternalBusyTimeService;
use SugarCalendarBookings\Integrations\IntegrationCapabilityRegistry;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionModel;
use SugarCalendarBookings\Integrations\Webhooks\WebhookSubscriptionModel;
use SugarCalendarBookings\Integrations\WebhookEventHandlerInterface;
use SugarCalendarBookings\Plugin;
use SugarCalendarBookings\Support\Bootable;

/**
 * Outlook webhook event handler.
 *
 * Processes Microsoft Graph subscription notifications forwarded by the
 * Product API relay. Reacts to event.created / event.updated / event.deleted
 * change types and keeps busy times and linked appointments in sync, mirroring
 * the Zoom integration:
 *  - created  → track busy time only (no appointment change).
 *  - updated  → refresh busy time, and reschedule the linked appointment to the
 *               new time (availability checks skipped — the change originated
 *               externally).
 *  - deleted  → drop the busy time and cancel the linked appointment via the
 *               established cancel pipeline.
 *
 * @since 1.5.0
 */
class OutlookWebhookEventHandler implements WebhookEventHandlerInterface, Bootable {

	/**
	 * Outlook actions client used to re-fetch event state.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookActionsClient
	 */
	private OutlookActionsClient $client;

	/**
	 * Per-connection settings repository.
	 *
	 * Single source of truth for the JSON `settings` shape on a connection
	 * row. Owns load/save plus the calendars cache.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookCalendarSettingsRepository
	 */
	private OutlookCalendarSettingsRepository $settings_repository;

	/**
	 * Constructor.
	 *
	 * @since 1.5.0
	 *
	 * @param OutlookActionsClient              $client              Outlook actions client.
	 * @param OutlookCalendarSettingsRepository $settings_repository Per-connection settings repository.
	 */
	public function __construct(
		OutlookActionsClient $client,
		OutlookCalendarSettingsRepository $settings_repository
	) {

		$this->client              = $client;
		$this->settings_repository = $settings_repository;
	}

	/**
	 * Register the handler in the capability registry.
	 *
	 * @since 1.5.0
	 */
	public function init(): void {

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

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

		return 'outlook';
	}

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

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

	/**
	 * Whether the handler is available.
	 *
	 * Always available when registered — the handler validates connections
	 * per-event from the forwarded subscription_id.
	 *
	 * @since 1.5.0
	 *
	 * @return bool
	 */
	public function is_available(): bool {

		return true;
	}

	/**
	 * Handle an incoming Outlook webhook event.
	 *
	 * The relay forwards two envelope shapes:
	 *  - Change notifications:  `{ "events": [ ... ] }` — a batch of one or
	 *    more Microsoft Graph notifications. Each entry is processed
	 *    independently so one bad event doesn't poison the rest of the batch.
	 *  - Connection failure:    `{ "event_type": "connection_failed",
	 *    "subscription_id": "...", "reason": "..." }` — sent when the relay
	 *    can no longer renew the subscription on our behalf.
	 *
	 * @since 1.5.0
	 *
	 * @param array $event_data Forwarded payload from the Product API relay.
	 */
	public function handle_event( array $event_data ): void {

		$event_type = isset( $event_data['event_type'] ) ? (string) $event_data['event_type'] : '';

		if ( $event_type === 'connection_failed' ) {
			$this->handle_connection_failed( $event_data );

			return;
		}

		$events = isset( $event_data['events'] ) && is_array( $event_data['events'] )
			? $event_data['events']
			: [];

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

		foreach ( $events as $event ) {

			if ( ! is_array( $event ) ) {
				continue;
			}

			$this->process_event( $event );
		}
	}

	/**
	 * Handle a `connection_failed` notification from the relay.
	 *
	 * Marks the owning OAuth connection as `auth_error`. The existing
	 * {@see OAuthConnectionErrorNotice} infrastructure surfaces a persistent
	 * admin notice on the Integrations settings page prompting the user to
	 * reconnect.
	 *
	 * @since 1.5.0
	 *
	 * @param array $event_data Forwarded payload.
	 */
	private function handle_connection_failed( array $event_data ): void {

		$subscription_id = isset( $event_data['subscription_id'] ) ? (string) $event_data['subscription_id'] : '';

		if ( $subscription_id === '' ) {
			return;
		}

		$connection = $this->find_connection_by_subscription( $subscription_id );

		if ( ! $connection ) {
			return;
		}

		( new OutlookOAuthConnection( $connection ) )->mark_auth_error();
	}

	/**
	 * Process a single forwarded Microsoft Graph notification.
	 *
	 * @since 1.5.0
	 *
	 * @param array $event Single notification entry.
	 */
	private function process_event( array $event ): void {

		$subscription_id = isset( $event['subscription_id'] ) ? (string) $event['subscription_id'] : '';
		$change_type     = isset( $event['change_type'] ) ? (string) $event['change_type'] : '';
		$event_id        = isset( $event['event_id'] ) ? (string) $event['event_id'] : '';

		if ( $subscription_id === '' || $change_type === '' || $event_id === '' ) {
			return;
		}

		$subscription = $this->find_subscription( $subscription_id );

		// Orphaned subscription — relay cleanup will eventually drop it.
		if ( $subscription === null ) {
			return;
		}

		$connection = OAuthConnectionModel::get( $subscription->connection_id );

		if ( ! $connection ) {
			return;
		}

		$calendar_id = (string) $subscription->resource_id;

		switch ( $change_type ) {
			case 'created':
				// A created event only matters as a busy-time source. Linked
				// appointments are authored by us (never synced in on create), so
				// non-conflict calendars — e.g. a write-only calendar — skip the
				// fetch entirely.
				if ( $this->is_conflict_calendar( $connection, $calendar_id ) ) {
					$event_state = $this->fetch_event( $connection, $event_id );

					if ( $event_state !== null ) {
						$this->store_busy_time( $connection, $calendar_id, $event_id, $event_state );
					}
				}
				break;

			case 'updated':
				$event_state = $this->fetch_event( $connection, $event_id );

				if ( $event_state === null ) {
					break;
				}

				// Busy time only for conflict calendars; appointment sync for any
				// subscribed calendar (so write-calendar appointment edits flow back).
				if ( $this->is_conflict_calendar( $connection, $calendar_id ) ) {
					$this->store_busy_time( $connection, $calendar_id, $event_id, $event_state );
				}

				$this->maybe_update_linked_appointment( $event_id, $event_state );
				break;

			case 'deleted':
				$this->handle_event_deleted( $connection, $event_id );
				break;
		}
	}

	/**
	 * Re-fetch a Microsoft Graph event by ID.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection Owning OAuth connection.
	 * @param string               $event_id   Microsoft Graph event ID.
	 *
	 * @return array|null The Graph event payload, or null when it could not be fetched
	 *                    (e.g. deleted between the notification and this re-fetch).
	 */
	private function fetch_event( OAuthConnectionModel $connection, string $event_id ): ?array {

		$event_state = $this->client->get_event( new OutlookOAuthConnection( $connection ), $event_id );

		if ( is_wp_error( $event_state ) || ! is_array( $event_state ) ) {
			return null;
		}

		return $event_state;
	}

	/**
	 * Whether a calendar is selected as a conflict (busy-time) source.
	 *
	 * The write calendar may be subscribed for appointment sync without being a
	 * conflict source, so busy-time storage gates on this rather than on the
	 * mere existence of a subscription.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection  Owning OAuth connection.
	 * @param string               $calendar_id Source calendar id (from the subscription mapping).
	 *
	 * @return bool
	 */
	private function is_conflict_calendar( OAuthConnectionModel $connection, string $calendar_id ): bool {

		if ( $calendar_id === '' ) {
			return false;
		}

		$settings     = $this->settings_repository->load( $connection );
		$conflict_ids = isset( $settings['conflict_calendar_ids'] ) && is_array( $settings['conflict_calendar_ids'] )
			? array_map( 'strval', $settings['conflict_calendar_ids'] )
			: [];

		return in_array( $calendar_id, $conflict_ids, true );
	}

	/**
	 * Store (or refresh) the busy-time entry for a calendar event.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection  Owning OAuth connection.
	 * @param string               $calendar_id Source calendar id (from the subscription mapping).
	 * @param string               $event_id    Microsoft Graph event ID.
	 * @param array                $event_state Fetched Graph event payload.
	 */
	private function store_busy_time( OAuthConnectionModel $connection, string $calendar_id, string $event_id, array $event_state ): void {

		$times = $this->resolve_event_times( $event_state );

		if ( $times === null ) {
			return;
		}

		Plugin::get( ExternalBusyTimeService::class )->store_busy_times(
			$connection,
			'outlook',
			$calendar_id,
			[
				[
					'provider_event_id' => $event_id,
					'title'             => isset( $event_state['subject'] ) ? (string) $event_state['subject'] : '',
					'start_at'          => $times['start_at'],
					'end_at'            => $times['end_at'],
					'is_all_day'        => ! empty( $event_state['isAllDay'] ),
				],
			]
		);
	}

	/**
	 * Reschedule a linked appointment when its Outlook event time changes.
	 *
	 * Mirrors the Zoom integration: only active (non-cancelled) appointments are
	 * moved, availability checks are skipped because the change originated
	 * externally, and unchanged times are a no-op. Appointment update failures
	 * are swallowed so they never break webhook processing.
	 *
	 * @since 1.5.0
	 *
	 * @param string $event_id    Microsoft Graph event ID.
	 * @param array  $event_state Fetched Graph event payload.
	 */
	private function maybe_update_linked_appointment( string $event_id, array $event_state ): void {

		$appointment = $this->find_appointment_by_event_id( $event_id );

		if ( ! $appointment || $appointment->status === AppointmentModel::STATUS_CANCELLED ) {
			return;
		}

		$times = $this->resolve_event_times( $event_state );

		if ( $times === null ) {
			return;
		}

		// Skip if the times haven't actually changed.
		if (
			$appointment->start_at->format( 'Y-m-d H:i:s' ) === $times['start_at'] &&
			$appointment->end_at->format( 'Y-m-d H:i:s' ) === $times['end_at']
		) {
			return;
		}

		$dto = new AppointmentDTO(
			[
				'start_at'            => $times['start_at'],
				'end_at'              => $times['end_at'],
				'ignore_availability' => true,
			]
		);

		try {
			Plugin::get( AppointmentService::class )->update( $appointment, $dto );
			// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
		} catch ( Exception $e ) {
			// Webhook processing should not break on appointment update errors.
		}
	}

	/**
	 * Handle a deleted change-type notification.
	 *
	 * Drops the busy-time entry for the event and, when the deleted event
	 * maps to one of our linked appointments, cancels that appointment via
	 * AppointmentService::cancel(). This fires the standard cancellation
	 * pipeline (notifications, hooks, refunds via downstream listeners).
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection Owning OAuth connection.
	 * @param string               $event_id   Microsoft Graph event ID.
	 */
	private function handle_event_deleted( OAuthConnectionModel $connection, string $event_id ): void {

		// Always drop any busy-time entry we may have tracked for this event.
		Plugin::get( ExternalBusyTimeService::class )->delete_by_provider_event( 'outlook', $event_id );

		$appointment = $this->find_appointment_by_event_id( $event_id );

		// Not one of ours — daily refresh will reconcile any remaining state.
		if ( ! $appointment ) {
			return;
		}

		// Race tolerance — appointment already cancelled.
		if ( $appointment->status === AppointmentModel::STATUS_CANCELLED ) {
			return;
		}

		/**
		 * Filter whether an external calendar-event deletion should cancel the linked appointment.
		 *
		 * Returning false short-circuits the cancellation while still letting the busy-time
		 * removal happen, allowing third-party code to gate destructive reconciliation.
		 *
		 * @since 1.5.0
		 *
		 * @param bool                 $should_cancel Whether to cancel the appointment.
		 * @param AppointmentModel     $appointment   The linked appointment.
		 * @param OAuthConnectionModel $connection    The owning OAuth connection.
		 * @param string               $event_id      The deleted Microsoft Graph event ID.
		 */
		$should_cancel = (bool) apply_filters(
			'sugar_calendar_bookings_calendar_event_link_cancellation',
			true,
			$appointment,
			$connection,
			$event_id
		);

		if ( ! $should_cancel ) {
			return;
		}

		Plugin::get( AppointmentService::class )->cancel( $appointment );
	}

	/**
	 * Find an OAuth connection by Microsoft Graph subscription ID.
	 *
	 * Resolves through the per-calendar {@see WebhookSubscriptionModel} mapping:
	 * the subscription id identifies a row that carries the owning connection.
	 *
	 * @since 1.5.0
	 *
	 * @param string $subscription_id Microsoft Graph subscription ID.
	 *
	 * @return OAuthConnectionModel|null
	 */
	private function find_connection_by_subscription( string $subscription_id ): ?OAuthConnectionModel {

		$subscription = $this->find_subscription( $subscription_id );

		if ( $subscription === null ) {
			return null;
		}

		return OAuthConnectionModel::get( (int) $subscription->connection_id );
	}

	/**
	 * Find the subscription mapping row for a Microsoft Graph subscription ID.
	 *
	 * @since 1.5.0
	 *
	 * @param string $subscription_id Microsoft Graph subscription ID.
	 *
	 * @return WebhookSubscriptionModel|null
	 */
	private function find_subscription( string $subscription_id ): ?WebhookSubscriptionModel {

		return WebhookSubscriptionModel::query()
			->where( 'subscription_id', '=', $subscription_id, '%s' )
			->where( 'provider', '=', $this->get_provider_slug(), '%s' )
			->first();
	}

	/**
	 * Find an appointment linked to a Microsoft Graph event ID.
	 *
	 * Searches appointment meta for an `external_calendar_events` entry whose
	 * `event_id` matches. The link array is stored as JSON inside the meta
	 * value, so a LIKE on the encoded fragment is sufficient and matches the
	 * scale we expect.
	 *
	 * @since 1.5.0
	 *
	 * @param string $event_id Microsoft Graph event ID.
	 *
	 * @return AppointmentModel|null
	 */
	private function find_appointment_by_event_id( string $event_id ): ?AppointmentModel {

		global $wpdb;

		// Match against BOTH the provider slug and the event id so we never
		// resolve to an unrelated appointment whose Zoom meeting (or other
		// provider) happens to share the same event id string.
		$provider_clause = $wpdb->esc_like( '"provider":"outlook"' );
		$event_clause    = $wpdb->esc_like( '"event_id":"' . $event_id . '"' );
		$like_value      = '%' . $provider_clause . '%' . $event_clause . '%';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$appointment_id = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT object_id FROM {$wpdb->prefix}scbookings_meta WHERE object_type = 'appointment' AND meta_key = 'external_calendar_events' AND meta_value LIKE %s LIMIT 1",
				$like_value
			)
		);

		if ( ! $appointment_id ) {
			return null;
		}

		return AppointmentModel::get( (int) $appointment_id );
	}

	/**
	 * Resolve start/end datetimes from a Microsoft Graph event payload.
	 *
	 * Microsoft returns start/end as `{ dateTime, timeZone }`. We normalise
	 * everything to UTC and a `Y-m-d H:i:s` string suitable for storage.
	 *
	 * @since 1.5.0
	 *
	 * @param array $event_state Graph event payload.
	 *
	 * @return array|null Array with `start_at`/`end_at`, or null if unparseable.
	 */
	private function resolve_event_times( array $event_state ): ?array {

		$start_raw = isset( $event_state['start']['dateTime'] ) ? (string) $event_state['start']['dateTime'] : '';
		$end_raw   = isset( $event_state['end']['dateTime'] ) ? (string) $event_state['end']['dateTime'] : '';

		if ( $start_raw === '' || $end_raw === '' ) {
			return null;
		}

		$utc      = new DateTimeZone( 'UTC' );
		$start_tz = isset( $event_state['start']['timeZone'] ) ? (string) $event_state['start']['timeZone'] : 'UTC';
		$end_tz   = isset( $event_state['end']['timeZone'] ) ? (string) $event_state['end']['timeZone'] : 'UTC';

		// Graph defaults to Windows TZ names (e.g. "Pacific Standard Time"),
		// which DateTimeZone rejects. Fall back to UTC if the zone is unknown —
		// `get_event()` requests Prefer: outlook.timezone="UTC" so the zone
		// label should already be "UTC" here in practice.
		$start_dt = $this->to_utc( $start_raw, $start_tz, $utc );
		$end_dt   = $this->to_utc( $end_raw, $end_tz, $utc );

		if ( $start_dt === null || $end_dt === null ) {
			return null;
		}

		return [
			'start_at' => $start_dt->format( 'Y-m-d H:i:s' ),
			'end_at'   => $end_dt->format( 'Y-m-d H:i:s' ),
		];
	}

	/**
	 * Convert a Graph datetime string + zone label into a UTC DateTimeImmutable.
	 *
	 * @since 1.5.0
	 *
	 * @param string       $raw     Graph datetime string.
	 * @param string       $tz_name Time-zone label from Graph.
	 * @param DateTimeZone $utc     Reusable UTC zone.
	 *
	 * @return DateTimeImmutable|null
	 */
	private function to_utc( string $raw, string $tz_name, DateTimeZone $utc ): ?DateTimeImmutable {

		try {
			$zone = new DateTimeZone( $tz_name );
		} catch ( Exception $e ) {
			$zone = $utc;
		}

		try {
			return ( new DateTimeImmutable( $raw, $zone ) )->setTimezone( $utc );
		} catch ( Exception $e ) {
			return null;
		}
	}
}
