<?php
/**
 * Availability service: works out which time slots are open on a date.
 *
 * Shared by the time-slot mode now, and by the date-range mode later. Given a
 * calendar, a date, and the field rules, it returns the open times with anything
 * already on the calendar removed.
 *
 * @package DrezSync
 */

defined( 'ABSPATH' ) || exit;

/**
 * Computes open time slots for a date.
 */
class DrezSync_Availability {

	/**
	 * Google Calendar connection.
	 *
	 * @var DrezSync_Connection
	 */
	private $connection;

	/**
	 * Constructor.
	 *
	 * @param DrezSync_Connection $connection Connection service.
	 */
	public function __construct( DrezSync_Connection $connection ) {
		$this->connection = $connection;
	}

	/**
	 * Open times for a date, as an array of 'H:i' strings.
	 *
	 * @param string $calendar_id Google Calendar ID.
	 * @param string $date        Date as Y-m-d.
	 * @param array  $rules       days, start, end, slot.
	 * @return array
	 */
	public function get_times( $calendar_id, $date, $rules ) {
		$times = array();

		if ( empty( $calendar_id ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
			return $times;
		}

		$tz = wp_timezone();

		try {
			$day = new DateTime( $date . ' 00:00:00', $tz );
		} catch ( \Exception $e ) {
			return $times;
		}

		$weekday   = (int) $day->format( 'w' ); // 0 = Sunday ... 6 = Saturday.
		$open_days = array_map( 'intval', (array) ( isset( $rules['days'] ) ? $rules['days'] : array( 1, 2, 3, 4, 5 ) ) );
		if ( ! in_array( $weekday, $open_days, true ) ) {
			return $times;
		}

		$slot     = max( 5, (int) ( isset( $rules['slot'] ) ? $rules['slot'] : 30 ) );
		$capacity = max( 1, (int) ( isset( $rules['capacity'] ) ? $rules['capacity'] : 1 ) );
		$start = ( isset( $rules['start'] ) && preg_match( '/^\d{1,2}:\d{2}$/', $rules['start'] ) ) ? $rules['start'] : '09:00';
		$end   = ( isset( $rules['end'] ) && preg_match( '/^\d{1,2}:\d{2}$/', $rules['end'] ) ) ? $rules['end'] : '17:00';

		try {
			$window_start = new DateTime( $date . ' ' . $start, $tz );
			$window_end   = new DateTime( $date . ' ' . $end, $tz );
		} catch ( \Exception $e ) {
			return $times;
		}

		if ( $window_end <= $window_start ) {
			return $times;
		}

		$busy = $this->get_busy( $calendar_id, $window_start, $window_end, $tz );
		$now  = new DateTime( 'now', $tz );

		$cursor = clone $window_start;
		$guard  = 0;
		while ( $cursor < $window_end && $guard < 300 ) {
			$guard++;
			$slot_end = clone $cursor;
			$slot_end->modify( '+' . $slot . ' minutes' );
			if ( $slot_end > $window_end ) {
				break;
			}
			if ( $cursor > $now && $this->count_overlaps( $cursor, $slot_end, $busy ) < $capacity ) {
				$times[] = $cursor->format( 'H:i' );
			}
			$cursor->modify( '+' . $slot . ' minutes' );
		}

		return $times;
	}

	/**
	 * Which days in a month have at least one open time slot.
	 *
	 * Used to grey out unavailable dates in the calendar picker. Makes a single
	 * calendar fetch for the whole month, then tests each day locally against the
	 * same rules get_times() uses, so it never hits the API per day.
	 *
	 * @param string $calendar_id Google Calendar ID.
	 * @param int    $year        Four digit year.
	 * @param int    $month       Month 1-12.
	 * @param array  $rules       days, start, end, slot, capacity.
	 * @return array Array of day-of-month integers that have availability.
	 */
	public function available_days( $calendar_id, $year, $month, $rules ) {
		$open = array();
		if ( empty( $calendar_id ) || $year < 2000 || $year > 2100 || $month < 1 || $month > 12 ) {
			return $open;
		}

		$tz = wp_timezone();
		try {
			$first = new DateTime( sprintf( '%04d-%02d-01 00:00:00', $year, $month ), $tz );
		} catch ( \Exception $e ) {
			return $open;
		}
		$next = clone $first;
		$next->modify( 'first day of next month' );

		$slot      = max( 5, (int) ( isset( $rules['slot'] ) ? $rules['slot'] : 30 ) );
		$capacity  = max( 1, (int) ( isset( $rules['capacity'] ) ? $rules['capacity'] : 1 ) );
		$open_days = array_map( 'intval', (array) ( isset( $rules['days'] ) ? $rules['days'] : array( 1, 2, 3, 4, 5 ) ) );
		$start     = ( isset( $rules['start'] ) && preg_match( '/^\d{1,2}:\d{2}$/', $rules['start'] ) ) ? $rules['start'] : '09:00';
		$end       = ( isset( $rules['end'] ) && preg_match( '/^\d{1,2}:\d{2}$/', $rules['end'] ) ) ? $rules['end'] : '17:00';

		$busy       = $this->get_busy( $calendar_id, $first, $next, $tz );
		$now        = new DateTime( 'now', $tz );
		$days_count = (int) $first->format( 't' );

		for ( $d = 1; $d <= $days_count; $d++ ) {
			$ymd = sprintf( '%04d-%02d-%02d', $year, $month, $d );
			try {
				$day = new DateTime( $ymd . ' 00:00:00', $tz );
			} catch ( \Exception $e ) {
				continue;
			}
			if ( ! in_array( (int) $day->format( 'w' ), $open_days, true ) ) {
				continue;
			}
			try {
				$window_start = new DateTime( $ymd . ' ' . $start, $tz );
				$window_end   = new DateTime( $ymd . ' ' . $end, $tz );
			} catch ( \Exception $e ) {
				continue;
			}
			if ( $window_end <= $window_start ) {
				continue;
			}

			$cursor = clone $window_start;
			$guard  = 0;
			while ( $cursor < $window_end && $guard < 300 ) {
				$guard++;
				$slot_end = clone $cursor;
				$slot_end->modify( '+' . $slot . ' minutes' );
				if ( $slot_end > $window_end ) {
					break;
				}
				if ( $cursor > $now && $this->count_overlaps( $cursor, $slot_end, $busy ) < $capacity ) {
					$open[] = $d;
					break;
				}
				$cursor->modify( '+' . $slot . ' minutes' );
			}
		}

		return $open;
	}


	/**
	 * Busy intervals from the calendar within the window.
	 *
	 * @param string       $calendar_id  Calendar ID.
	 * @param DateTime     $window_start Window start.
	 * @param DateTime     $window_end   Window end.
	 * @param DateTimeZone $tz           Site timezone.
	 * @return array Array of array( DateTime $start, DateTime $end ).
	 */
	private function get_busy( $calendar_id, $window_start, $window_end, $tz ) {
		$busy   = array();
		$client = $this->connection->get_client();
		if ( ! $client || ! class_exists( '\Google\Service\Calendar' ) ) {
			return $busy;
		}

		try {
			$service = new \Google\Service\Calendar( $client );
			$params  = array(
				'timeMin'      => $window_start->format( 'c' ),
				'timeMax'      => $window_end->format( 'c' ),
				'singleEvents' => true,
				'orderBy'      => 'startTime',
				'maxResults'   => 250,
			);

			// Page through results. A single day never needs this, but a whole month
			// (the calendar-greying query) can exceed one page on a busy calendar.
			// Capped so a runaway never loops forever.
			$page  = 0;
			$token = '';
			do {
				if ( '' !== $token ) {
					$params['pageToken'] = $token;
				}
				$events = $service->events->listEvents( $calendar_id, $params );
				foreach ( $events->getItems() as $event ) {
					$s = $event->getStart();
					$e = $event->getEnd();
					if ( ! $s || ! $e ) {
						continue;
					}
					$s_dt   = $s->getDateTime() ? new DateTime( $s->getDateTime() ) : new DateTime( $s->getDate(), $tz );
					$e_dt   = $e->getDateTime() ? new DateTime( $e->getDateTime() ) : new DateTime( $e->getDate(), $tz );
					$busy[] = array( $s_dt, $e_dt );
				}
				$token = method_exists( $events, 'getNextPageToken' ) ? (string) $events->getNextPageToken() : '';
				$page++;
			} while ( '' !== $token && $page < 12 );
		} catch ( \Exception $e ) {
			return $busy;
		}

		return $busy;
	}

	/**
	 * How many busy intervals overlap a slot (its current booking count).
	 *
	 * @param DateTime $slot_start Slot start.
	 * @param DateTime $slot_end   Slot end.
	 * @param array    $busy       Busy intervals.
	 * @return int
	 */
	private function count_overlaps( $slot_start, $slot_end, $busy ) {
		$count = 0;
		foreach ( $busy as $interval ) {
			if ( $slot_start < $interval[1] && $slot_end > $interval[0] ) {
				$count++;
			}
		}
		return $count;
	}
}
