<?php

namespace SugarCalendarBookings\Integrations\Zoom;

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

/**
 * Zoom webhook event handler.
 *
 * Processes Zoom meeting lifecycle events (created/updated/deleted)
 * to keep busy times and linked appointments in sync.
 *
 * @since 1.4.0
 */
class ZoomWebhookEventHandler implements WebhookEventHandlerInterface, Bootable {

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

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

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

		return 'zoom';
	}

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

		return 'Zoom';
	}

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

		return true;
	}

	/**
	 * Handle a Zoom webhook event.
	 *
	 * @since 1.4.0
	 *
	 * @param array $event_data Zoom webhook event payload.
	 */
	public function handle_event( array $event_data ): void {

		$event_type = $event_data['event'] ?? '';
		$payload    = $event_data['payload'] ?? [];
		$meeting    = $payload['object'] ?? [];

		if ( empty( $meeting['id'] ) ) {
			return;
		}

		$meeting_id = (string) $meeting['id'];

		switch ( $event_type ) {
			case 'meeting.created':
				// Only track busy time — no appointment changes for externally created meetings.
				$this->upsert_busy_time( $meeting_id, $meeting, $payload );
				break;

			case 'meeting.recovered':
				$this->upsert_busy_time( $meeting_id, $meeting, $payload );
				$this->maybe_restore_linked_appointment( $meeting_id, $meeting );
				break;

			case 'meeting.updated':
				$this->upsert_busy_time( $meeting_id, $meeting, $payload );
				$this->maybe_update_linked_appointment( $meeting_id, $meeting );
				break;

			case 'meeting.deleted':
				Plugin::get( ExternalBusyTimeService::class )->delete_by_provider_event( 'zoom', $meeting_id );
				$this->maybe_cancel_linked_appointment( $meeting_id );
				break;

			case 'meeting.permanently_deleted':
				Plugin::get( ExternalBusyTimeService::class )->delete_by_provider_event( 'zoom', $meeting_id );
				$this->maybe_delete_linked_appointment( $meeting_id );
				break;
		}
	}

	/**
	 * Upsert a Zoom meeting as a busy time entry.
	 *
	 * @since 1.4.0
	 *
	 * @param string $meeting_id Zoom meeting ID.
	 * @param array  $meeting    Meeting object from webhook payload.
	 * @param array  $payload    Full payload (contains operator_id/account_id).
	 */
	private function upsert_busy_time( string $meeting_id, array $meeting, array $payload ): void {

		// Zoom webhooks use operator_id for the acting user (matches our stored account_id).
		// The payload's account_id is the org-level Zoom account, not the user.
		$user_id = $payload['operator_id'] ?? $payload['account_id'] ?? '';

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

		$connection = OAuthConnectionModel::query()
			->where( 'provider', '=', 'zoom', '%s' )
			->where( 'account_id', '=', $user_id, '%s' )
			->where( 'status', '=', 'active', '%s' )
			->first();

		if ( ! $connection ) {
			return;
		}

		// Zoom update webhooks only include changed fields.
		// Merge with existing record to avoid wiping data.
		$existing = ExternalBusyTimeModel::query()
			->where( 'provider', '=', 'zoom', '%s' )
			->where( 'provider_event_id', '=', $meeting_id, '%s' )
			->first();

		$title = $meeting['topic'] ?? ( $existing ? $existing->title : '' );
		$times = $this->resolve_times( $meeting, $existing );

		if ( ! $times ) {
			return;
		}

		Plugin::get( ExternalBusyTimeService::class )->upsert_meeting_busy_time(
			$connection,
			'zoom',
			$meeting_id,
			$title,
			$times['start_at'],
			$times['end_at']
		);
	}

	/**
	 * Update a linked appointment when a Zoom meeting is updated.
	 *
	 * Only updates start_at/end_at if the webhook includes new time data.
	 * Skips availability checks since the change originated externally.
	 *
	 * @since 1.4.0
	 *
	 * @param string $meeting_id Zoom meeting ID.
	 * @param array  $meeting    Meeting object from webhook payload.
	 */
	private function maybe_update_linked_appointment( string $meeting_id, array $meeting ): void {

		if ( empty( $meeting['start_time'] ) ) {
			return;
		}

		$appointment = $this->find_appointment_by_meeting_id( $meeting_id );

		// Only update active (approved/pending) appointments.
		if ( ! $appointment || $appointment->status === AppointmentModel::STATUS_CANCELLED ) {
			return;
		}

		try {
			$start_dt = new DateTimeImmutable( $meeting['start_time'], new DateTimeZone( 'UTC' ) );
		} catch ( Exception $e ) {
			return;
		}

		$fallback = (int) ( ( $appointment->end_at->getTimestamp() - $appointment->start_at->getTimestamp() ) / 60 );
		$duration = $this->resolve_duration( $meeting, $fallback );
		$end_dt   = $start_dt->modify( '+' . $duration . ' minutes' );

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

		$dto = new AppointmentDTO(
			[
				'start_at'            => $start_dt->format( 'Y-m-d H:i:s' ),
				'end_at'              => $end_dt->format( 'Y-m-d H:i:s' ),
				'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.
		}
	}

	/**
	 * Cancel a linked appointment when a Zoom meeting is deleted.
	 *
	 * @since 1.4.0
	 *
	 * @param string $meeting_id Zoom meeting ID.
	 */
	private function maybe_cancel_linked_appointment( string $meeting_id ): void {

		$appointment = $this->find_appointment_by_meeting_id( $meeting_id );

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

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

	/**
	 * Delete a linked appointment when a Zoom meeting is permanently deleted.
	 *
	 * @since 1.4.0
	 *
	 * @param string $meeting_id Zoom meeting ID.
	 */
	private function maybe_delete_linked_appointment( string $meeting_id ): void {

		$appointment = $this->find_appointment_by_meeting_id( $meeting_id );

		if ( ! $appointment ) {
			return;
		}

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

	/**
	 * Restore a linked appointment when a Zoom meeting is recovered from trash.
	 *
	 * Restores the appointment to approved status since Zoom meetings
	 * are only created for approved appointments.
	 *
	 * @since 1.4.0
	 *
	 * @param string $meeting_id Zoom meeting ID.
	 * @param array  $meeting    Meeting object from webhook payload.
	 */
	private function maybe_restore_linked_appointment( string $meeting_id, array $meeting ): void {

		$appointment = $this->find_appointment_by_meeting_id( $meeting_id );

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

		$data = [
			'status'              => AppointmentModel::STATUS_APPROVED,
			'ignore_availability' => true,
		];

		// Also sync times if the recovered meeting has time data.
		if ( ! empty( $meeting['start_time'] ) ) {
			try {
				$start_dt = new DateTimeImmutable( $meeting['start_time'], new DateTimeZone( 'UTC' ) );
				$fallback = (int) ( ( $appointment->end_at->getTimestamp() - $appointment->start_at->getTimestamp() ) / 60 );
				$duration = $this->resolve_duration( $meeting, $fallback );
				$end_dt   = $start_dt->modify( '+' . $duration . ' minutes' );

				$data['start_at'] = $start_dt->format( 'Y-m-d H:i:s' );
				$data['end_at']   = $end_dt->format( 'Y-m-d H:i:s' );
			} catch ( Exception $e ) {
				// Use existing times if parsing fails.
			}
		}

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

	/**
	 * Find an appointment by its Zoom meeting ID.
	 *
	 * Searches the meta table for an online_location containing the meeting_id.
	 *
	 * @since 1.4.0
	 *
	 * @param string $meeting_id Zoom meeting ID.
	 *
	 * @return AppointmentModel|null
	 */
	private function find_appointment_by_meeting_id( string $meeting_id ): ?AppointmentModel {

		global $wpdb;

		// Use the dedicated provider_meeting_id meta key for indexed equality lookup.
		// 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 = 'provider_meeting_id' AND meta_value = %s LIMIT 1",
				$meeting_id
			)
		);

		if ( ! $appointment_id ) {
			return null;
		}

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

	/**
	 * Resolve start/end times from webhook data or existing record.
	 *
	 * @since 1.4.0
	 *
	 * @param array                      $meeting  Meeting object from webhook payload.
	 * @param ExternalBusyTimeModel|null $existing Existing busy time record.
	 *
	 * @return array|null Array with start_at/end_at strings, or null if unresolvable.
	 */
	private function resolve_times( array $meeting, ?ExternalBusyTimeModel $existing ): ?array {

		if ( ! empty( $meeting['start_time'] ) ) {
			try {
				$start_dt = new DateTimeImmutable( $meeting['start_time'], new DateTimeZone( 'UTC' ) );
			} catch ( Exception $e ) {
				return null;
			}

			$fallback = $existing
				? (int) ( ( $existing->end_at->getTimestamp() - $existing->start_at->getTimestamp() ) / 60 )
				: 60;

			$duration = $this->resolve_duration( $meeting, $fallback );
			$end_dt   = $start_dt->modify( '+' . $duration . ' minutes' );

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

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

		return null;
	}

	/**
	 * Resolve meeting duration from webhook data with fallback.
	 *
	 * @since 1.4.0
	 *
	 * @param array $meeting          Meeting object from webhook payload.
	 * @param int   $fallback_minutes Fallback duration in minutes.
	 *
	 * @return int Duration in minutes (minimum 60).
	 */
	private function resolve_duration( array $meeting, int $fallback_minutes ): int {

		$duration = isset( $meeting['duration'] ) ? (int) $meeting['duration'] : $fallback_minutes;

		return $duration > 0 ? $duration : 60;
	}
}
