<?php
/**
 * Google Calendar Feed
 *
 * @package SimpleCalendar/Feeds
 */
namespace SimpleCalendar\Feeds;

use SimpleCalendar\plugin_deps\Google\Collection as Google_Collection;
use SimpleCalendar\plugin_deps\Google\Model as Google_Model;
use SimpleCalendar\plugin_deps\Google\Service\Calendar as Google_Service_Calendar;
use SimpleCalendar\plugin_deps\Google\Client as Google_Client;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\Event as Google_Service_Calendar_Event;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventAttendee as Google_Service_Calendar_EventAttendee;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventAttachment as Google_Service_Calendar_EventAttachment;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventBirthdayProperties as Google_Service_Calendar_EventBirthdayProperties;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventCreator as Google_Service_Calendar_EventCreator;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventDateTime as Google_Service_Calendar_EventDateTime;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventExtendedProperties as Google_Service_Calendar_EventExtendedProperties;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventFocusTimeProperties as Google_Service_Calendar_EventFocusTimeProperties;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventGadget as Google_Service_Calendar_EventGadget;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventOrganizer as Google_Service_Calendar_EventOrganizer;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventOutOfOfficeProperties as Google_Service_Calendar_EventOutOfOfficeProperties;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventReminder as Google_Service_Calendar_EventReminder;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventReminders as Google_Service_Calendar_EventReminders;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventSource as Google_Service_Calendar_EventSource;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\EventWorkingLocationProperties as Google_Service_Calendar_EventWorkingLocationProperties;
use SimpleCalendar\plugin_deps\Google\Service\Calendar\Events as Google_Service_Calendar_Events;
use SimpleCalendar\plugin_deps\Google\Service\Exception as Google_Service_Exception;

use SimpleCalendar\plugin_deps\Carbon\Carbon;
use SimpleCalendar\Abstracts\Calendar;
use SimpleCalendar\Abstracts\Feed;
use SimpleCalendar\Feeds\Admin\Google_Admin as Admin;
use SimpleCalendar\plugin_deps\GuzzleHttp\Client;

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

/**
 * Google Calendar feed.
 *
 * A feed using a simple Google API key to pull events from public calendars.
 *
 * @since 3.0.0
 */
class Google extends Feed
{
	/**
	 * Google API Client.
	 *
	 * @access private
	 * @var \Google_Client
	 */
	protected $google_client = null;

	/**
	 * Client scopes.
	 *
	 * @access private
	 * @var array
	 */
	protected $google_client_scopes = [];

	/**
	 * Google Calendar API key.
	 *
	 * @access protected
	 * @var string
	 */
	protected $google_api_key = '';

	/**
	 * Google Calendar ID.
	 *
	 * @access protected
	 * @var string
	 */
	protected $google_calendar_id = '';

	/**
	 * Google recurring events query setting.
	 *
	 * @access protected
	 * @var string
	 */
	protected $google_events_recurring = '';

	/**
	 * Google search query setting.
	 *
	 * @access protected
	 * @var string
	 */
	protected $google_search_query = '';

	/**
	 * Google max results query setting.
	 *
	 * @access protected
	 * @var int
	 */
	protected $google_max_results = 2500;

	/**
	 * Set properties.
	 *
	 * @since 3.0.0
	 *
	 * @param string|Calendar $calendar
	 * @param bool $load_admin
	 */
	public function __construct($calendar = '', $load_admin = true)
	{
		parent::__construct($calendar);

		$this->type = 'google';
		$this->name = __('Google Calendar', 'google-calendar-events');

		// Google client config.
		$settings = get_option('simple-calendar_settings_feeds');
		$this->google_api_key = isset($settings['google']['api_key']) ? esc_attr($settings['google']['api_key']) : '';
		$this->google_client_scopes = [Google_Service_Calendar::CALENDAR_READONLY];

		if ($this->post_id > 0) {
			// Google query args.
			$this->google_calendar_id = $this->esc_google_calendar_id(
				get_post_meta($this->post_id, '_google_calendar_id', true),
			);
			$this->google_events_recurring = esc_attr(get_post_meta($this->post_id, '_google_events_recurring', true));
			// note that google_search_query is used in a URL param and not as HTML output, so don't use esc_attr() on it
			$this->google_search_query = get_post_meta($this->post_id, '_google_events_search_query', true);
			$this->google_max_results = max(absint(get_post_meta($this->post_id, '_google_events_max_results', true)), 1);

			if (!is_admin() || defined('DOING_AJAX')) {
				$this->events = !empty($this->google_api_key) ? $this->get_events() : [];
			}
		}

		if (is_admin() && $load_admin) {
			$admin = new Admin($this, $this->google_api_key, $this->google_calendar_id);
			$this->settings = $admin->settings_fields();
		}
	}

	/**
	 * Decode a calendar id.
	 *
	 * @since  3.0.0
	 *
	 * @param  string $id Base64 encoded id.
	 *
	 * @return string
	 */
	public function esc_google_calendar_id($id)
	{
		return base64_decode($id);
	}

	/**
	 * Get events feed.
	 *
	 * Normalizes Google data into a standard array object to list events.
	 *
	 * @since  3.0.0
	 *
	 * @return string|array
	 */
	public function get_events()
	{
		$calendar = get_transient('_simple-calendar_feed_id_' . strval($this->post_id) . '_' . $this->type);

		if (empty($calendar) && !empty($this->google_calendar_id)) {
			$error = '';

			try {
				$response = $this->make_request($this->google_calendar_id);
			} catch (Google_Service_Exception $e) {
				$error .= $e->getMessage();
			}

			if (empty($error) && isset($response['events']) && isset($response['timezone'])) {
				$calendar = array_merge($response, ['events' => []]);

				// If no timezone has been set, use calendar feed.
				if ('use_calendar' == $this->timezone_setting) {
					$this->timezone = $calendar['timezone'];
				}

				$source = isset($response['title']) ? sanitize_text_field($response['title']) : '';

				if (!empty($response['events']) && is_array($response['events'])) {
					foreach ($response['events'] as $event) {
						if ($event instanceof Google_Service_Calendar_Event) {
							// Visibility and status.
							// Public calendars may have private events which can't be properly accessed by simple api key method.
							// Also want to skip cancelled events (single occurences deleted from repeating events)
							$visibility = $event->getVisibility();
							$status = $event->getStatus();
							if (
								$this->type == 'google' &&
								($visibility == 'private' || $visibility == 'confidential' || $status == 'cancelled')
							) {
								continue;
							}

							// Event title & description.
							$title = $event->getSummary() ?? '';
							$title = strip_tags($title);
							$title = !empty($title)
								? sanitize_text_field(iconv(mb_detect_encoding($title, mb_detect_order(), true), 'UTF-8', $title))
								: '';

							$description = $event->getDescription() ?? '';
							$description = !empty($description)
								? wp_kses_post(iconv(mb_detect_encoding($description, mb_detect_order(), true), 'UTF-8', $description))
								: '';

							$whole_day = false;

							// Event start properties.
							if ('use_calendar' == $this->timezone_setting) {
								$start_timezone = !$event->getStart()->timeZone ? $calendar['timezone'] : $event->getStart()->timeZone;
							} else {
								$start_timezone = $this->timezone;
							}

							if (is_null($event->getStart()->dateTime)) {
								// Whole day event.
								$date = Carbon::parse($event->getStart()->date);
								$google_start = Carbon::createFromDate($date->year, $date->month, $date->day, $start_timezone)
									->startOfDay()
									->addSeconds(59);
								$google_start_utc = Carbon::createFromDate($date->year, $date->month, $date->day, 'UTC')
									->startOfDay()
									->addSeconds(59);
								$whole_day = true;
							} else {
								$date = Carbon::parse($event->getStart()->dateTime);

								// Check if there is an event level timezone
								if ($event->getStart()->timeZone && 'use_calendar' == $this->timezone_setting) {
									// Get the two different times with the separate timezones so we can check the offsets next
									$google_start1 = Carbon::create(
										$date->year,
										$date->month,
										$date->day,
										$date->hour,
										$date->minute,
										$date->second,
										$date->timezone,
									);
									$google_start2 = Carbon::create(
										$date->year,
										$date->month,
										$date->day,
										$date->hour,
										$date->minute,
										$date->second,
										$event->getStart()->timeZone,
									);

									// Get the offset in hours
									$offset1 = $google_start1->offsetHours;
									$offset2 = $google_start2->offsetHours;

									// Get the difference between the two timezones
									$total_offset = $offset2 - $offset1;

									// Add the hours offset to the date hour
									$date->hour += $total_offset;
								}

								$google_start = Carbon::create(
									$date->year,
									$date->month,
									$date->day,
									$date->hour,
									$date->minute,
									$date->second,
									$start_timezone,
								);
								$google_start_utc = Carbon::create(
									$date->year,
									$date->month,
									$date->day,
									$date->hour,
									$date->minute,
									$date->second,
									'UTC',
								);

								$this->timezone = $start_timezone;
							}
							// Start.
							$start = $google_start->getTimestamp();
							// Start UTC.
							$start_utc = $google_start_utc->getTimestamp();

							$end = $end_utc = $end_timezone = '';
							$span = 0;
							if (false == $event->getEndTimeUnspecified()) {
								// Event end properties.
								if ('use_calendar' == $this->timezone_setting) {
									$end_timezone = !$event->getEnd()->timeZone ? $calendar['timezone'] : $event->getEnd()->timeZone;
								} else {
									$end_timezone = $this->timezone;
								}

								if (is_null($event->getEnd()->dateTime)) {
									// Whole day event.
									$date = Carbon::parse($event->getEnd()->date);
									$google_end = Carbon::createFromDate($date->year, $date->month, $date->day, $end_timezone)
										->startOfDay()
										->subSeconds(59);
									$google_end_utc = Carbon::createFromDate($date->year, $date->month, $date->day, 'UTC')
										->startOfDay()
										->subSeconds(59);
								} else {
									$date = Carbon::parse($event->getEnd()->dateTime);

									// Check if there is an event level timezone
									if ($event->getEnd()->timeZone && 'use_calendar' == $this->timezone_setting) {
										// Get the two different times with the separate timezones so we can check the offsets next
										$google_start1 = Carbon::create(
											$date->year,
											$date->month,
											$date->day,
											$date->hour,
											$date->minute,
											$date->second,
											$date->timezone,
										);
										$google_start2 = Carbon::create(
											$date->year,
											$date->month,
											$date->day,
											$date->hour,
											$date->minute,
											$date->second,
											$event->getEnd()->timeZone,
										);

										// Get the offset in hours
										$offset1 = $google_start1->offsetHours;
										$offset2 = $google_start2->offsetHours;

										// Get the difference between the two timezones
										$total_offset = $offset2 - $offset1;

										// Add the hours offset to the date hour
										$date->hour += $total_offset;
									}

									$google_end = Carbon::create(
										$date->year,
										$date->month,
										$date->day,
										$date->hour,
										$date->minute,
										$date->second,
										$end_timezone,
									);
									$google_end_utc = Carbon::create(
										$date->year,
										$date->month,
										$date->day,
										$date->hour,
										$date->minute,
										$date->second,
										'UTC',
									);
								}
								// End.
								$end = $google_end->getTimestamp();
								// End UTC.
								$end_utc = $google_end_utc->getTimestamp();

								// Count multiple days.
								$span = $google_start->diffInDays($google_end->endOfDay());

								if ($span == 0) {
									if (
										$google_start->toDateString() !== $google_end->toDateString() &&
										$google_end->toTimeString() != '00:00:00'
									) {
										$span = 1;
									}
								}
							}

							// Multiple days.
							$multiple_days = $span > 0 ? $span : false;

							// Google cannot have two different locations for start and end time.
							$start_location = $end_location = $event->getLocation();

							// Recurring event.
							$recurrence = $event->getRecurrence();
							$recurring_id = $event->getRecurringEventId();
							if (!$recurrence && $recurring_id) {
								$recurrence = true;
							}

							// Event link.
							if ('use_calendar' == $this->timezone_setting) {
								$link = esc_url(add_query_arg(['ctz' => $this->timezone], $event->getHtmlLink()));
							} else {
								$link = $event->getHtmlLink();
							}

							// Build the event.
							$calendar['events'][intval($start)][] = [
								'type' => 'google-calendar',
								'source' => $source,
								'title' => $title,
								'description' => $description,
								'link' => $link,
								'visibility' => $visibility,
								'uid' => $event->id,
								'ical_id' => $event->getICalUID(),
								'calendar' => $this->post_id,
								'timezone' => $this->timezone,
								'start' => $start,
								'start_utc' => $start_utc,
								'start_timezone' => $start_timezone,
								'start_location' => $start_location,
								'end' => $end,
								'end_utc' => $end_utc,
								'end_timezone' => $end_timezone,
								'end_location' => $end_location,
								'whole_day' => $whole_day,
								'multiple_days' => $multiple_days,
								'recurrence' => $recurrence,
								'template' => $this->events_template,
							];
						}
					}

					if (!empty($calendar['events'])) {
						ksort($calendar['events'], SORT_NUMERIC);
					}

					set_transient(
						'_simple-calendar_feed_id_' . strval($this->post_id) . '_' . $this->type,
						$calendar,
						max(absint($this->cache), 1), // Since a value of 0 means forever we set the minimum here to 1 if the user has set it to be 0
					);
				}
			} else {
				$message = __('While trying to retrieve events, Google returned an error:', 'google-calendar-events');
				$message .= '<br><br>' . $error . '<br><br>';
				$message .=
					__(
						'Please ensure that both your Google Calendar ID and API Key are valid and that the Google Calendar you want to display is public.',
						'google-calendar-events',
					) . '<br><br>';
				$message .= __('Only you can see this notice.', 'google-calendar-events');

				return $message;
			}
		}

		// If no timezone has been set, use calendar feed.
		if ('use_calendar' == $this->timezone_setting && isset($calendar['timezone'])) {
			$this->timezone = $calendar['timezone'];
		}

		// Custom OR search since Google API doesn't support the OR search for calendar events
		if (isset($calendar['events']) && !empty($this->google_search_query)) {
			$events = [];
			$search_query = explode(strtolower(' OR '), strtolower($this->google_search_query));

			foreach ($calendar['events'] as $k => $v) {
				foreach ($v as $k2) {
					$search_found = false;

					for ($i = 0; $i < count($search_query); $i++) {
						$title_check = !empty($k2['title']) ? strpos(strtolower($k2['title']), $search_query[$i]) : false;
						$description_check = !empty($k2['description'])
							? strpos(strtolower($k2['description']), $search_query[$i])
							: false;

						if ($title_check !== false || $description_check !== false) {
							$search_found = true;
						}
					}

					if ($search_found) {
						$events[$k] = $v;
					}
				}
			}

			if (!empty($events)) {
				$calendar['events'] = $events;
			}
		}

		return isset($calendar['events']) ? $calendar['events'] : [];
	}

	/**
	 * Query Google Calendar.
	 *
	 * @since  3.0.0
	 *
	 * @param  string $id        A valid Google Calendar ID.
	 * @param  int    $time_min  Lower bound timestamp.
	 * @param  int    $time_max  Upper bound timestamp.
	 *
	 * @return array
	 *
	 * @throws Exception On request failure will throw an exception from Google.
	 */
	public function make_request($id = '', $time_min = 0, $time_max = 0)
	{
		$calendar = [];
		$google = $this->get_service();

		if (!is_null($google) && !empty($id)) {
			// Build the request args.
			$args = [];

			// Expand recurring events.
			if ('show' === $this->google_events_recurring) {
				$args['singleEvents'] = true;
			}

			// Query events using search terms.
			if (!empty($this->google_search_query) && strpos($this->google_search_query, 'OR') === false) {
				$args['q'] = rawurlencode($this->google_search_query);
			}

			// Max results to query.
			$args['maxResults'] = strval(min(absint($this->google_max_results), 2500));

			// Specify a timezone.
			$timezone = '';
			if ('use_calendar' != get_post_meta($this->post_id, '_feed_timezone_setting', true)) {
				$args['timeZone'] = $timezone = $this->timezone;
			}

			// Lower bound (inclusive) for an event's end time to filter by.
			$earliest_event = intval($this->time_min);
			if ($earliest_event > 0) {
				$timeMin = Carbon::now();
				if (!empty($timezone)) {
					$timeMin->setTimezone($timezone);
				}
				$timeMin->setTimestamp($earliest_event);
				$args['timeMin'] = $timeMin->toRfc3339String();
			}

			// Upper bound (exclusive) for an event's start time to filter by.
			$latest_event = intval($this->time_max);
			if ($latest_event > 0) {
				$timeMax = Carbon::now();
				if (!empty($timezone)) {
					$timeMax->setTimezone($timezone);
				}
				$timeMax->setTimestamp($latest_event);
				$args['timeMax'] = $timeMax->toRfc3339String();
			}

			// Trying to order by startTime for non-single events throws
			// Google v3 API 400 error - "The requested ordering is not available for the particular query.".
			// Only set this conditionally.
			if (isset($args['singleEvents']) && true === $args['singleEvents']) {
				$args['orderBy'] = 'startTime';
			}

			$feed_type = wp_get_object_terms($this->post_id, 'calendar_feed');

			// Query events in calendar.
			$simple_calendar_auth_site_token = get_option('simple_calendar_auth_site_token');
			$response = '';
			$backgroundcolor = '';
			if (
				isset($simple_calendar_auth_site_token) &&
				!empty($simple_calendar_auth_site_token) &&
				isset($feed_type[0]->slug) &&
				$feed_type[0]->slug != 'google'
			) {
				$response_arr = apply_filters('simple_calendar_oauth_list_events', '', $id, $args);

				if (!is_array($response_arr)) {
					throw new Google_Service_Exception(
						__('Unable to fetch calendar events via OAuth.', 'google-calendar-events'),
						1,
					);
				}

				if (isset($response_arr['Error']) && !empty($response_arr['Error'])) {
					throw new Google_Service_Exception($response_arr['Error'], 1);
				}

				if (!isset($response_arr['data']) || !is_string($response_arr['data']) || $response_arr['data'] === '') {
					$message = !empty($response_arr['message'])
						? (string) $response_arr['message']
						: __('Unable to fetch calendar events via OAuth.', 'google-calendar-events');
					throw new Google_Service_Exception($message, 1);
				}

				$response = $this->oauth_unserialize_events_response($response_arr['data']);
				if (isset($response_arr['backgroundcolor']) && !empty($response_arr['backgroundcolor'])) {
					$backgroundcolor = $response_arr['backgroundcolor'];
				}

				if (is_array($response) && isset($response['Error']) && !empty($response['Error'])) {
					throw new Google_Service_Exception($response['Error'], 1);
				}
			} else {
				$response = $google->events->listEvents($id, $args);
			}

			if ($response instanceof Google_Service_Calendar_Events) {
				$calendar = [
					'id' => $id,
					'title' => $response->getSummary(),
					'description' => $response->getDescription(),
					'timezone' => $response->getTimeZone(),
					'url' => esc_url('//www.google.com/calendar/embed?src=' . $id),
					'events' => $response->getItems(),
					'backgroundcolor' => $backgroundcolor,
				];
			}
		}

		return $calendar;
	}

	/**
	 * Unserialize OAuth proxy events response with a restricted class allowlist.
	 *
	 * @since  3.5.4
	 *
	 * @param string $data Serialized events payload from the auth proxy.
	 * @return mixed
	 */
	private function oauth_unserialize_events_response($data)
	{
		return unserialize($data, [
			'allowed_classes' => [
				Google_Service_Calendar_Events::class,
				Google_Service_Calendar_Event::class,
				Google_Service_Calendar_EventAttendee::class,
				Google_Service_Calendar_EventAttachment::class,
				Google_Service_Calendar_EventBirthdayProperties::class,
				Google_Service_Calendar_EventCreator::class,
				Google_Service_Calendar_EventDateTime::class,
				Google_Service_Calendar_EventExtendedProperties::class,
				Google_Service_Calendar_EventFocusTimeProperties::class,
				Google_Service_Calendar_EventGadget::class,
				Google_Service_Calendar_EventOrganizer::class,
				Google_Service_Calendar_EventOutOfOfficeProperties::class,
				Google_Service_Calendar_EventReminder::class,
				Google_Service_Calendar_EventReminders::class,
				Google_Service_Calendar_EventSource::class,
				Google_Service_Calendar_EventWorkingLocationProperties::class,
				Google_Model::class,
				Google_Collection::class,
			],
		]);
	}

	/**
	 * Google API Client.
	 *
	 * @since  3.0.0
	 * @access private
	 *
	 * @return \Google_Client
	 */
	private function get_client()
	{
		if ($this->google_client instanceof Google_Client) {
			return $this->google_client;
		}

		static $clients = [];

		$curl_options = apply_filters('simcal_google_client_curl_options', []);
		$cache_key = md5(
			$this->google_api_key . wp_json_encode($this->google_client_scopes) . wp_json_encode($curl_options),
		);
		if (isset($clients[$cache_key])) {
			$this->google_client = $clients[$cache_key];
			return $this->google_client;
		}

		$client = new Google_Client();
		$client->setApplicationName('Simple Calendar');
		$client->setScopes($this->google_client_scopes);
		$client->setDeveloperKey($this->google_api_key);
		$client->setAccessType('online');

		if (!empty($curl_options)) {
			$guzzle = new Client([
				'curl.options' => $curl_options,
			]);

			$client->setHttpClient($guzzle);
		}

		$clients[$cache_key] = $client;
		$this->google_client = $client;

		return $client;
	}

	/**
	 * Google Calendar Service.
	 *
	 * @since  3.0.0
	 * @access protected
	 *
	 * @return null|Google_Service_Calendar
	 */
	protected function get_service()
	{
		$client = $this->get_client();

		return $client instanceof Google_Client ? new Google_Service_Calendar($client) : null;
	}
}
