<?php

namespace SugarCalendarBookings\Integrations\Outlook;

use DateTimeImmutable;
use DateTimeZone;
use Exception;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionInterface;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionModel;
use SugarCalendarBookings\Support\Helpers\Date;

/**
 * Outlook busy-time service.
 *
 * Owns the Graph `getSchedule` request shape, the response unwrapping, and
 * the busy-time normalization used by the external conflict checker.
 *
 * @since 1.5.0
 */
class OutlookBusyTimeService {

	/**
	 * Maximum busy time duration in minutes (matches the max service duration).
	 *
	 * Protects against unexpected data from the Graph API.
	 *
	 * @since 1.5.0
	 *
	 * @var int
	 */
	private const MAX_BUSY_TIME_DURATION = 720;

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

	/**
	 * Calendar settings repository.
	 *
	 * @since 1.5.0
	 *
	 * @var OutlookCalendarSettingsRepository
	 */
	private OutlookCalendarSettingsRepository $settings;

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

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

	/**
	 * Fetch busy times across the connection's configured conflict calendars.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel     $connection OAuth connection.
	 * @param OAuthConnectionInterface $wrapped    Wrapped connection used for the Graph call.
	 * @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(
		OAuthConnectionModel $connection,
		OAuthConnectionInterface $wrapped,
		string $from,
		string $to
	): array {

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

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

		$schedules = array_values( array_unique( array_filter( [ (string) $connection->account_email ] ) ) );

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

		$body = [
			'schedules'                => $schedules,
			'startTime'                => [
				'dateTime' => gmdate( 'Y-m-d\TH:i:s', strtotime( $from ) ),
				'timeZone' => 'UTC',
			],
			'endTime'                  => [
				'dateTime' => gmdate( 'Y-m-d\TH:i:s', strtotime( $to ) ),
				'timeZone' => 'UTC',
			],
			'availabilityViewInterval' => 60,
		];

		$response = $this->client->get_schedule( $wrapped, $body );

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

		return $this->transform_schedule_response( $response );
	}

	/**
	 * Fetch events from each configured conflict calendar and return them grouped by calendar ID.
	 *
	 * Uses the calendar-scoped events endpoint instead of getSchedule, so it
	 * works for personal (Outlook.com) and work/school (Office 365) accounts
	 * alike and respects the exact calendar selection the user configured.
	 *
	 * @since 1.5.0
	 *
	 * @param OAuthConnectionModel     $connection OAuth connection.
	 * @param OAuthConnectionInterface $wrapped    Wrapped connection used for the Graph call.
	 * @param string                   $from       Start datetime (Y-m-d H:i:s).
	 * @param string                   $to         End datetime (Y-m-d H:i:s).
	 *
	 * @return array<string, array> Busy-time entries grouped by calendar ID.
	 */
	public function fetch_by_calendars(
		OAuthConnectionModel $connection,
		OAuthConnectionInterface $wrapped,
		string $from,
		string $to
	): array {

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

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

		$from_utc = gmdate( 'Y-m-d\TH:i:s', strtotime( $from ) );
		$to_utc   = gmdate( 'Y-m-d\TH:i:s', strtotime( $to ) );

		$grouped = [];

		foreach ( $conflict_calendar_ids as $calendar_id ) {

			$response = $this->client->list_calendar_events( $wrapped, $calendar_id, $from_utc, $to_utc );

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

			$events = $response['value'] ?? [];

			if ( ! is_array( $events ) || empty( $events ) ) {
				continue;
			}

			$entries = [];

			foreach ( $events as $event ) {
				$entry = $this->event_to_busy_time( $event );

				if ( $entry !== null ) {
					$entries[] = $entry;
				}
			}

			if ( ! empty( $entries ) ) {
				$grouped[ $calendar_id ] = $entries;
			}
		}

		return $grouped;
	}

	/**
	 * Convert a single Graph calendar event to a busy-time entry.
	 *
	 * Handles timezone conversion — Graph returns start/end in the event's
	 * stored timezone, not necessarily UTC.
	 *
	 * @since 1.5.0
	 *
	 * @param mixed $item Graph calendar event.
	 *
	 * @return array|null
	 */
	private function event_to_busy_time( $item ): ?array {

		if ( ! is_array( $item ) ) {
			return null;
		}

		$start_raw = $item['start']['dateTime'] ?? '';
		$end_raw   = $item['end']['dateTime'] ?? '';

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

		$utc           = new DateTimeZone( 'UTC' );
		$start_tz_name = isset( $item['start']['timeZone'] ) ? (string) $item['start']['timeZone'] : 'UTC';
		$end_tz_name   = isset( $item['end']['timeZone'] ) ? (string) $item['end']['timeZone'] : 'UTC';

		$start_dt = $this->to_utc( $start_raw, $start_tz_name, $utc );
		$end_dt   = $this->to_utc( $end_raw, $end_tz_name, $utc );

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

		$is_all_day       = ! empty( $item['isAllDay'] );
		$duration_minutes = ( $end_dt->getTimestamp() - $start_dt->getTimestamp() ) / 60;

		if ( $duration_minutes <= 0 ) {
			return null;
		}

		// Cap duration only for timed events — all-day and multi-day events
		// (e.g., vacations) legitimately exceed the per-appointment maximum.
		if ( ! $is_all_day && $duration_minutes > self::MAX_BUSY_TIME_DURATION ) {
			return null;
		}

		$event_id = isset( $item['id'] ) && $item['id'] !== ''
			? (string) $item['id']
			: md5( $start_raw . '|' . $end_raw . '|' . ( $item['subject'] ?? '' ) );

		return [
			'provider_event_id' => $event_id,
			'title'             => isset( $item['subject'] ) ? (string) $item['subject'] : '',
			'start_at'          => $start_dt->format( 'Y-m-d H:i:s' ),
			'end_at'            => $end_dt->format( 'Y-m-d H:i:s' ),
			'is_all_day'        => $is_all_day,
		];
	}

	/**
	 * Transform a Graph `getSchedule` response into busy-time entries.
	 *
	 * @since 1.5.0
	 *
	 * @param array $response Graph response payload.
	 *
	 * @return array
	 */
	private function transform_schedule_response( array $response ): array {

		$schedules = $response['value'] ?? [];

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

		$entries = [];

		foreach ( $schedules as $schedule ) {
			if ( ! is_array( $schedule ) ) {
				continue;
			}

			$items = $schedule['scheduleItems'] ?? [];

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

			foreach ( $items as $item ) {
				$entry = $this->schedule_item_to_busy_time( $item );

				if ( $entry !== null ) {
					$entries[] = $entry;
				}
			}
		}

		return $entries;
	}

	/**
	 * Convert a single Graph scheduleItem to a busy time entry.
	 *
	 * @since 1.5.0
	 *
	 * @param mixed $item Schedule item from Graph.
	 *
	 * @return array|null
	 */
	private function schedule_item_to_busy_time( $item ): ?array {

		if ( ! is_array( $item ) ) {
			return null;
		}

		$start_raw = $item['start']['dateTime'] ?? '';
		$end_raw   = $item['end']['dateTime'] ?? '';

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

		$start_dt = Date::create( $start_raw );
		$end_dt   = Date::create( $end_raw );

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

		$duration_minutes = ( $end_dt->getTimestamp() - $start_dt->getTimestamp() ) / 60;

		if ( $duration_minutes <= 0 || $duration_minutes > self::MAX_BUSY_TIME_DURATION ) {
			return null;
		}

		// Use a stable derived ID since Graph's getSchedule doesn't expose event IDs.
		$event_id = isset( $item['id'] ) && $item['id'] !== ''
			? (string) $item['id']
			: md5( $start_raw . '|' . $end_raw . '|' . ( $item['subject'] ?? '' ) );

		return [
			'provider_event_id' => $event_id,
			'title'             => isset( $item['subject'] ) ? (string) $item['subject'] : '',
			'start_at'          => $start_dt->format( 'Y-m-d H:i:s' ),
			'end_at'            => $end_dt->format( 'Y-m-d H:i:s' ),
			'is_all_day'        => false,
		];
	}

	/**
	 * Convert a Graph datetime string + zone label into a UTC DateTimeImmutable.
	 *
	 * Graph returns Windows time-zone names (e.g. "Pacific Standard Time") by
	 * default unless a `Prefer: outlook.timezone="UTC"` header is set.
	 * `new DateTimeZone()` throws on Windows names — fall back to UTC and rely
	 * on the upstream `Prefer` header for actual zone correctness.
	 *
	 * @since 1.5.0
	 *
	 * @param string       $raw     Graph datetime string (Y-m-d\TH:i:s[.uuuuuuu]).
	 * @param string       $tz_name Time-zone label from the Graph payload.
	 * @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;
		}
	}
}
