<?php

namespace SugarCalendarBookings\Integrations\Outlook;

use SugarCalendarBookings\Integrations\IntegrationCapabilityRegistry;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionModel;
use SugarCalendarBookings\Integrations\Webhooks\WebhookSubscriptionModel;
use SugarCalendarBookings\Integrations\WebhookSubscriptionLifecycleInterface;
use SugarCalendarBookings\Plugin;
use SugarCalendarBookings\Support\Bootable;

/**
 * Outlook subscription manager.
 *
 * Manages Microsoft Graph subscriptions on a per-calendar basis: one
 * subscription per (connection, conflict calendar), each watching
 * `/me/calendars/{id}/events`. The relay-issued subscription id is stored in
 * {@see WebhookSubscriptionModel} alongside the calendar it watches, so the
 * webhook handler can resolve an incoming notification's source calendar from
 * the local mapping rather than from the (unreliable) event payload.
 *
 * Registers itself as the {@see WebhookSubscriptionLifecycleInterface}
 * capability for the `outlook` provider so {@see OAuthConnectionModel::delete()}
 * can dispatch teardown without admin/settings code knowing about Outlook.
 *
 * @since 1.5.0
 */
class OutlookSubscriptionManager implements WebhookSubscriptionLifecycleInterface, Bootable {

	/**
	 * Microsoft Graph resource template (one calendar's events).
	 *
	 * The `%s` placeholder is the calendar id. A subscription on a specific
	 * calendar makes the source calendar deterministic — the calendar whose
	 * subscription fired — without expanding it from the event payload.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	const RESOURCE_TEMPLATE = '/me/calendars/%s/events';

	/**
	 * Change types we want to receive.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	const CHANGE_TYPES = 'created,updated,deleted';

	/**
	 * Initial subscription lifetime, in seconds.
	 *
	 * Microsoft Graph caps event subscriptions at 4230 minutes (~70.5h). We
	 * pick 60 hours to leave headroom — the relay's daily cron renews anything
	 * expiring soon, so the absolute initial value is not critical past "well
	 * under the Graph cap".
	 *
	 * @since 1.5.0
	 *
	 * @var int
	 */
	const INITIAL_LIFETIME_SECONDS = 60 * 3600;

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

	/**
	 * Per-connection settings repository.
	 *
	 * @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 this capability with the integration 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 capability is available.
	 *
	 * @since 1.5.0
	 *
	 * @return bool
	 */
	public function is_available(): bool {

		return true;
	}

	/**
	 * Reconcile subscriptions for a connection against the calendars it watches.
	 *
	 * The watched set is the union of the selected conflict calendars and the
	 * write calendar: conflict calendars feed busy-time checks, while the write
	 * calendar is subscribed so edits to our pushed appointment events sync back
	 * (the webhook handler decides which concern applies per calendar). Registers
	 * a subscription for every watched calendar that does not yet have one, and
	 * tears down subscriptions for calendars no longer watched. Safe to call on
	 * connect and whenever the calendar selection changes.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection  OAuth connection to reconcile.
	 * @param string               $webhook_url Receiver URL the relay will POST events to.
	 */
	public function sync( OAuthConnectionModel $connection, string $webhook_url ): void {

		$selected_ids = $this->watched_calendar_ids( $connection );
		$existing_ids = $this->existing_calendar_ids( $connection );

		foreach ( array_diff( $selected_ids, $existing_ids ) as $calendar_id ) {
			$this->register_for_calendar( $connection, $calendar_id, $webhook_url );
		}

		foreach ( array_diff( $existing_ids, $selected_ids ) as $calendar_id ) {
			$this->unregister_calendar( $connection, $calendar_id );
		}
	}

	/**
	 * Resolve the set of calendars a connection should hold a subscription for.
	 *
	 * The union of the selected conflict calendars and the (non-empty) write
	 * calendar. Deduplicated; empty ids are dropped.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection OAuth connection.
	 *
	 * @return string[]
	 */
	private function watched_calendar_ids( OAuthConnectionModel $connection ): array {

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

		$write_id = isset( $settings['write_calendar_id'] ) ? (string) $settings['write_calendar_id'] : '';

		if ( $write_id !== '' ) {
			$conflict_ids[] = $write_id;
		}

		return array_values( array_unique( array_filter( $conflict_ids, 'strlen' ) ) );
	}

	/**
	 * Register a Microsoft Graph subscription for a single calendar.
	 *
	 * Idempotent: when a subscription row already exists for the calendar its
	 * stored id is returned without creating a duplicate. Errors are swallowed
	 * because subscription registration is a best-effort side-effect — the
	 * OAuth connection itself remains valid even if the call fails.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection  OAuth connection to subscribe.
	 * @param string               $calendar_id Calendar id to watch.
	 * @param string               $webhook_url Receiver URL the relay will POST events to.
	 *
	 * @return string|null Subscription id, or null on failure.
	 */
	public function register_for_calendar( OAuthConnectionModel $connection, string $calendar_id, string $webhook_url ): ?string {

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

		$existing = $this->find_row( $connection, $calendar_id );

		if ( $existing !== null ) {
			return (string) $existing->subscription_id;
		}

		$expiration = gmdate( 'Y-m-d\TH:i:s\Z', time() + self::INITIAL_LIFETIME_SECONDS );

		$response = $this->client->create_subscription(
			new OutlookOAuthConnection( $connection ),
			[
				'site_webhook_url'   => $webhook_url,
				'oauth_app_id'       => $connection->app_id,
				'refresh_token'      => $connection->refresh_token,
				'resource'           => sprintf( self::RESOURCE_TEMPLATE, $calendar_id ),
				'changeType'         => self::CHANGE_TYPES,
				'expirationDateTime' => $expiration,
			]
		);

		$subscription_id = $this->extract_subscription_id( $response );

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

		$response_expiry = is_array( $response ) && ! empty( $response['expirationDateTime'] )
			? (string) $response['expirationDateTime']
			: $expiration;

		WebhookSubscriptionModel::create(
			[
				'connection_id'   => (int) $connection->id,
				'provider'        => $this->get_provider_slug(),
				'resource_id'     => $calendar_id,
				'subscription_id' => $subscription_id,
				'expires_at'      => gmdate( 'Y-m-d H:i:s', strtotime( $response_expiry ) ?: time() ),
				'status'          => WebhookSubscriptionModel::STATUS_ACTIVE,
			]
		);

		return $subscription_id;
	}

	/**
	 * Tear down the subscription for a single calendar.
	 *
	 * No-op when no subscription row exists for the calendar. The relay treats
	 * an already-gone subscription as success, so the local row is removed
	 * regardless of the relay response to avoid orphaned mappings.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection  OAuth connection.
	 * @param string               $calendar_id Calendar id to stop watching.
	 */
	public function unregister_calendar( OAuthConnectionModel $connection, string $calendar_id ): void {

		$row = $this->find_row( $connection, $calendar_id );

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

		$this->client->delete_subscription(
			new OutlookOAuthConnection( $connection ),
			(string) $row->subscription_id
		);

		$row->delete();
	}

	/**
	 * Tear down every subscription owned by a connection.
	 *
	 * Used on disconnect, before the connection row is removed.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection OAuth connection being disconnected.
	 */
	public function unregister_all( OAuthConnectionModel $connection ): void {

		$rows = WebhookSubscriptionModel::query()
			->where( 'connection_id', '=', (int) $connection->id, '%d' )
			->where( 'provider', '=', $this->get_provider_slug(), '%s' )
			->get();

		$wrapped = new OutlookOAuthConnection( $connection );

		foreach ( $rows as $row ) {

			$this->client->delete_subscription( $wrapped, (string) $row->subscription_id );

			$row->delete();
		}
	}

	/**
	 * Tear down the provider-side subscriptions tied to the given connection.
	 *
	 * {@see WebhookSubscriptionLifecycleInterface} entry point dispatched by
	 * {@see OAuthConnectionModel::delete()}.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection Connection being disconnected.
	 */
	public function unregister( OAuthConnectionModel $connection ): void {

		$this->unregister_all( $connection );
	}

	/**
	 * Calendar ids that currently have a subscription row for the connection.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection OAuth connection.
	 *
	 * @return string[]
	 */
	private function existing_calendar_ids( OAuthConnectionModel $connection ): array {

		$rows = WebhookSubscriptionModel::query()
			->where( 'connection_id', '=', $connection->id, '%d' )
			->where( 'provider', '=', $this->get_provider_slug(), '%s' )
			->get();

		return array_map(
			static function ( $row ) {

				return (string) $row->resource_id;
			},
			$rows
		);
	}

	/**
	 * Find the subscription row for a connection + calendar pair.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel $connection  OAuth connection.
	 * @param string               $calendar_id Calendar id.
	 *
	 * @return WebhookSubscriptionModel|null
	 */
	private function find_row( OAuthConnectionModel $connection, string $calendar_id ): ?WebhookSubscriptionModel {

		return WebhookSubscriptionModel::query()
			->where( 'connection_id', '=', (int) $connection->id, '%d' )
			->where( 'provider', '=', $this->get_provider_slug(), '%s' )
			->where( 'resource_id', '=', $calendar_id, '%s' )
			->first();
	}

	/**
	 * Extract the subscription id from a relay/Graph create response.
	 *
	 * @since 1.5.0
	 *
	 * @param mixed $response Response returned by the actions client.
	 *
	 * @return string|null Subscription id, or null on failure / unrecognised shape.
	 */
	private function extract_subscription_id( $response ): ?string {

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

		$candidates = [
			$response['id'] ?? null,
			$response['subscription_id'] ?? null,
			$response['subscription']['id'] ?? null,
			$response['data']['id'] ?? null,
		];

		foreach ( $candidates as $candidate ) {

			if ( is_string( $candidate ) && $candidate !== '' ) {
				return $candidate;
			}
		}

		return null;
	}
}
