<?php

namespace SugarCalendarBookings\Integrations;

use SugarCalendarBookings\Appointments\AppointmentModel;
use SugarCalendarBookings\Integrations\CalendarEvents\CalendarEventDispatcher;
use SugarCalendarBookings\Integrations\CalendarEvents\DeliverableConnectionMap;
use SugarCalendarBookings\Integrations\CalendarEvents\DeliverableConnectionResolver;
use SugarCalendarBookings\Integrations\CalendarEvents\EventLinkCollection;
use SugarCalendarBookings\Integrations\CalendarEvents\EventLinkDiff;
use SugarCalendarBookings\Integrations\CalendarEvents\EventLinkDiffer;
use SugarCalendarBookings\Integrations\CalendarEvents\EventLinkRepository;
use SugarCalendarBookings\Integrations\Webhooks\IncomingWebhookHandler;
use SugarCalendarBookings\Support\Bootable;

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

/**
 * Calendar Event Manager.
 *
 * Provider-agnostic orchestrator that listens to appointment lifecycle hooks
 * and coordinates push/update/delete via the CalendarEvents collaborators.
 *
 * Sibling to MeetingManager: meetings are per-online-location, calendar events
 * are per-user. Every appointment owned by a connected user is mirrored as a
 * calendar event regardless of online_location.
 *
 * @since 1.5.0
 */
class CalendarEventManager implements Bootable {

	/**
	 * Integration capability registry.
	 *
	 * @since 1.5.0
	 *
	 * @var IntegrationCapabilityRegistry
	 */
	private IntegrationCapabilityRegistry $registry;

	/**
	 * Event link repository.
	 *
	 * @since 1.5.0
	 *
	 * @var EventLinkRepository
	 */
	private EventLinkRepository $repository;

	/**
	 * Deliverable connection resolver.
	 *
	 * @since 1.5.0
	 *
	 * @var DeliverableConnectionResolver
	 */
	private DeliverableConnectionResolver $resolver;

	/**
	 * Event link differ.
	 *
	 * @since 1.5.0
	 *
	 * @var EventLinkDiffer
	 */
	private EventLinkDiffer $differ;

	/**
	 * Calendar event dispatcher.
	 *
	 * @since 1.5.0
	 *
	 * @var CalendarEventDispatcher
	 */
	private CalendarEventDispatcher $dispatcher;

	/**
	 * Constructor.
	 *
	 * @since 1.5.0
	 *
	 * @param IntegrationCapabilityRegistry $registry   Capability registry.
	 * @param EventLinkRepository           $repository Event link repository.
	 * @param DeliverableConnectionResolver $resolver   Deliverable connection resolver.
	 * @param EventLinkDiffer               $differ     Event link differ.
	 * @param CalendarEventDispatcher       $dispatcher Calendar event dispatcher.
	 */
	public function __construct(
		IntegrationCapabilityRegistry $registry,
		EventLinkRepository $repository,
		DeliverableConnectionResolver $resolver,
		EventLinkDiffer $differ,
		CalendarEventDispatcher $dispatcher
	) {

		$this->registry   = $registry;
		$this->repository = $repository;
		$this->resolver   = $resolver;
		$this->differ     = $differ;
		$this->dispatcher = $dispatcher;
	}

	/**
	 * Check if the calendar event manager should load.
	 *
	 * Only register hooks when at least one calendar event provider is registered.
	 * Note: providers register during init(), before hooks() is called.
	 *
	 * @since 1.5.0
	 *
	 * @return bool
	 */
	public function allow_load(): bool {

		return count( $this->registry->get( CalendarEventProviderInterface::class ) ) > 0;
	}

	/**
	 * Register WordPress hook listeners.
	 *
	 * @since 1.5.0
	 */
	public function hooks(): void {

		EventLinkRepository::register_meta();

		// phpcs:disable WPForms.PHP.ValidateHooks.InvalidHookName
		add_action( 'sugar_calendar_bookings_appointment_created', [ $this, 'maybe_push_events' ] );
		add_action( 'sugar_calendar_bookings_appointment_updated', [ $this, 'maybe_update_events' ] );
		add_action( 'sugar_calendar_bookings_appointment_cancelled', [ $this, 'maybe_cancel_events' ] );
		add_action( 'sugar_calendar_bookings_appointment_deleted', [ $this, 'maybe_delete_events' ] );
		add_action( 'sugar_calendar_bookings_appointment_finalized', [ $this, 'maybe_push_events' ] );
		// phpcs:enable WPForms.PHP.ValidateHooks.InvalidHookName
	}

	/**
	 * Maybe push calendar events for the appointment.
	 *
	 * Pushes one event per deliverable connection that isn't already linked.
	 * Idempotent on the (provider, connection_id) key.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment Target appointment.
	 */
	public function maybe_push_events( AppointmentModel $appointment ): void {

		$existing    = $this->repository->read( $appointment );
		$deliverable = $this->resolver->for_appointment( $appointment );

		if ( $deliverable->is_empty() ) {
			return;
		}

		$diff = $this->differ->diff( $existing, $deliverable );

		// For the push hook, only the to_push portion matters. Build a push-only
		// diff so the dispatcher doesn't touch updates/deletes on creation.
		$push_only = new EventLinkDiff(
			$diff->to_push(),
			[],
			[],
			$existing->all()
		);

		$result = $this->dispatcher->dispatch( $appointment, $push_only );

		if ( ! $result->new_links()->is_empty() || $existing->is_empty() === false ) {
			$this->repository->write( $appointment, $result->new_links() );
			$appointment->save();
		}
	}

	/**
	 * Maybe update calendar events when the appointment is updated.
	 *
	 * Updates existing links, deletes links whose connection is no longer
	 * deliverable, and pushes events for newly deliverable connections.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment Target appointment.
	 */
	public function maybe_update_events( AppointmentModel $appointment ): void {

		// Skip provider API calls when the change originated from a provider webhook.
		if ( IncomingWebhookHandler::is_processing() ) {
			return;
		}

		$existing = $this->repository->read( $appointment );

		// Re-approval from cancelled: the previously-linked event is marked
		// cancelled on the provider side. Drop the stale link and let the diff
		// push a fresh event rather than attempting to un-cancel the old one.
		if (
			! $existing->is_empty()
			&& $appointment->has_changed( 'status' )
			&& $appointment->get_previous( 'status' ) === 'cancelled'
			&& $appointment->status !== 'cancelled'
		) {
			$this->repository->write( $appointment, EventLinkCollection::empty() );
			$appointment->save();

			$existing = EventLinkCollection::empty();
		}

		// Cancelled appointments don't get further calendar mutations.
		// maybe_cancel_events already sent the cancellation notification;
		// subsequent edits (notes, custom fields) shouldn't touch it.
		if ( $appointment->status === 'cancelled' ) {
			return;
		}

		$deliverable = $this->resolver->for_appointment( $appointment );

		if ( $deliverable->is_empty() && $existing->is_empty() ) {
			return;
		}

		// Skip the Graph PATCH when nothing the synced event cares about has
		// changed (notes, custom fields, internal flags). Mirrors the same
		// guard MeetingManager uses for Zoom updates. Only applied when the
		// link set already matches the deliverable set — a connection coming
		// or going still needs the diff/dispatch to push/delete on its own.
		if (
			! $existing->is_empty()
			&& ! $this->has_event_relevant_changes( $appointment )
			&& $this->existing_matches_deliverable( $existing, $deliverable )
		) {
			return;
		}

		$diff   = $this->differ->diff( $existing, $deliverable );
		$result = $this->dispatcher->dispatch( $appointment, $diff );

		$this->repository->write( $appointment, $result->new_links() );
		$appointment->save();
	}

	/**
	 * Whether any field that affects the synced event payload has changed.
	 *
	 * Status changes are included so the `showAs = busy|tentative` flip on
	 * pending↔approved still pushes a PATCH.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment Updated appointment.
	 *
	 * @return bool
	 */
	private function has_event_relevant_changes( AppointmentModel $appointment ): bool {

		$relevant_fields = [
			'start_at',
			'end_at',
			'status',
			'employee_id',
			'customer_id',
			'service_id',
			'location_id',
			'online_location',
		];

		foreach ( $relevant_fields as $field ) {
			if ( $appointment->has_changed( $field ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether the persisted link set already matches the current deliverable set.
	 *
	 * When they diverge, the diff still needs to run so newly-deliverable
	 * connections get a push and lost-connection links get a delete, even if
	 * no payload field changed.
	 *
	 * @since 1.5.0
	 *
	 * @param EventLinkCollection      $existing    Persisted links.
	 * @param DeliverableConnectionMap $deliverable Current deliverable snapshot.
	 *
	 * @return bool
	 */
	private function existing_matches_deliverable(
		EventLinkCollection $existing,
		DeliverableConnectionMap $deliverable
	): bool {

		foreach ( $deliverable->provider_slugs() as $slug ) {

			foreach ( $deliverable->for_provider( $slug ) as $connection ) {

				if ( ! $existing->has( $slug, (int) $connection->id ) ) {
					return false;
				}
			}
		}

		foreach ( $existing->all() as $link ) {

			$slug          = $link->provider_slug();
			$connection_id = $link->connection_id();

			if ( ! in_array( $slug, $deliverable->provider_slugs(), true ) ) {
				continue;
			}

			$found = false;

			foreach ( $deliverable->for_provider( $slug ) as $connection ) {
				if ( $connection->id === $connection_id ) {
					$found = true;

					break;
				}
			}

			if ( ! $found ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Cancel calendar events when an appointment is cancelled.
	 *
	 * Dispatches a cancel notification to every provider that supports
	 * CancellableCalendarEventProviderInterface. The remote event is NOT
	 * deleted — it remains in the organiser's calendar in a cancelled state
	 * until the appointment is hard-deleted. The local meta link is kept so
	 * the subsequent hard-delete can still target the event.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment Cancelled appointment.
	 */
	public function maybe_cancel_events( AppointmentModel $appointment ): void {

		if ( IncomingWebhookHandler::is_processing() ) {
			return;
		}

		$existing = $this->repository->read( $appointment );

		if ( $existing->is_empty() ) {
			return;
		}

		$this->dispatcher->dispatch_cancel_all( $appointment, $existing );
	}

	/**
	 * Delete calendar events when an appointment is hard-deleted.
	 *
	 * Meta cleanup is handled by DeleteManager — no write needed here.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment Hard-deleted appointment.
	 */
	public function maybe_delete_events( AppointmentModel $appointment ): void {

		if ( IncomingWebhookHandler::is_processing() ) {
			return;
		}

		$existing = $this->repository->read( $appointment );

		if ( $existing->is_empty() ) {
			return;
		}

		$this->dispatcher->dispatch_delete_all( $appointment, $existing );
	}
}
