<?php

namespace Sugar_Calendar\Admin\Events\Tables;

use Sugar_Calendar\Admin\Area;
use Sugar_Calendar\Event;
use Sugar_Calendar\Event_Query;
use Sugar_Calendar\Helper;
use Sugar_Calendar\Features\Tags\Common\Helpers as TagsHelper;
use WP_List_Table;
use function Sugar_Calendar\Admin\Screen\Options\get_defaults;
use Sugar_Calendar\AddOn\Ticketing\Renderer as TicketingRenderer;

// Include the main list table class if it's not included.
if ( ! class_exists( 'WP_List_Table' ) ) {
	require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}

/**
 * Event table.
 *
 * This list table is responsible for showing events in a traditional table,
 * even though it extends the `WP_List_Table` class. Tables & lists & tables.
 *
 * @since 3.0.0
 */
class Base extends WP_List_Table {

	/**
	 * The mode of the current view.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	public $mode = 'list';

	/**
	 * What day does a calendar week start on.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	public $start_of_week = '1';

	/**
	 * The days of the week.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	public $week_days = [];

	/**
	 * Number of days per week.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	public $day_count = 7;

	/**
	 * How should dates be formatted.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	public $date_format = 'F j, Y';

	/**
	 * How should times be formatted.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	public $time_format = 'g:i a';

	/**
	 * The beginning boundary for the current view.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	public $view_start = '';

	/**
	 * The end boundary for the current view.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	public $view_end = '';

	/**
	 * Duration of view, from start to end.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	public $view_duration = 0;

	/**
	 * The current time zone object, derived from $view_timezone.
	 *
	 * @since 2.1.0
	 *
	 * @var object
	 */
	public $view_timezone = false;

	/**
	 * The items with pointers.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	public $pointers = [];

	/**
	 * The start year being viewed (for list-mode).
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	protected $start_year = 2020;

	/**
	 * The year being viewed.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	protected $year = 2020;

	/**
	 * The month being viewed.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	protected $month = 1;

	/**
	 * The day being viewed.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	protected $day = 1;

	/**
	 * The exact day being viewed based on year/month/day.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	protected $today = 0;

	/**
	 * The timestamp of the first second today.
	 *
	 * @since 3.10.0
	 *
	 * @var int
	 */
	protected $today_first_second;

	/**
	 * The time zone for the current view.
	 *
	 * @since 2.1.0
	 *
	 * @var object
	 */
	protected $timezone = 'UTC';

	/**
	 * The timestamp for this exact microsecond.
	 *
	 * We cache this as a reference to avoid repeated calls to time(),
	 * particularly when the accuracy of time comparisons is critical.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	protected $now = 0;

	/**
	 * Whether the week column is shown.
	 *
	 * @since 2.0.0
	 *
	 * @var bool
	 */
	protected $show_week_column = false;

	/**
	 * Current item details.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	protected $item = null;

	/**
	 * The events query for the current view items.
	 *
	 * @since 2.0.0
	 *
	 * @var object
	 */
	protected $query = null;

	/**
	 * The events query for recurring items.
	 *
	 * @since 2.0.0
	 *
	 * @var object
	 */
	protected $recurring_query = null;

	/**
	 * Unix time month start.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	public $grid_start = 0;

	/**
	 * Unix time month end.
	 *
	 * @since 2.0.0
	 *
	 * @var int
	 */
	protected $grid_end = 0;

	/**
	 * The properties for the current cell.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	protected $current_cell = [
		'day'   => null,
		'month' => null,
		'year'  => null,
		'start' => null,
		'end'   => null,
		'index' => null,
	];

	/**
	 * The properties for all of the cells.
	 *
	 * @since 2.1.3
	 *
	 * @var array
	 */
	protected $cells = [];

	/**
	 * Array of all items to loop through.
	 *
	 * May include clones, for items that recur.
	 *
	 * @since 2.2.0
	 *
	 * @var array
	 */
	protected $all_items = [];

	/**
	 * Array of queried items, filtered, usually by status.
	 *
	 * @since 2.1.2
	 *
	 * @var array
	 */
	protected $filtered_items = [];

	/**
	 * Array of queried item IDs, filtered, usually by status.
	 *
	 * @since 2.2.0
	 *
	 * @var array
	 */
	protected $filtered_ids = [];

	/**
	 * Array of item counts, from queried items that fit into this view.
	 *
	 * @since 2.1.6
	 *
	 * @var array
	 */
	protected $item_counts = [
		'total' => 0,
	];

	/**
	 * Maximum number of events per iteration.
	 *
	 * @since 3.0.0
	 *
	 * @var int
	 */
	protected $max = 0;

	/**
	 * The main constructor method.
	 *
	 * @since 2.0.0
	 *
	 * @param array $args Constructor arguments.
	 */
	public function __construct( $args = [] ) {

		// Initialize this class.
		$r = $this->init( $args );

		// Pass arguments into parent.
		parent::__construct( $r );
	}

	/** Init ******************************************************************/

	/**
	 * Initialize this class.
	 *
	 * @since 2.1.6
	 *
	 * @param array $args Initialization arguments.
	 *
	 * @return array
	 */
	protected function init( $args = [] ) {

		// Override the list table if one was passed in.
		if ( ! empty( $args['list_table'] ) ) {
			$this->set_list_table( $args['list_table'] );
		}

		// Set class properties.
		$this->init_globals();
		$this->init_timezone();
		$this->init_boundaries();
		$this->init_week_days();
		$this->init_max();
		$this->init_modes();

		// Get post type.
		$pt = sugar_calendar_get_admin_post_type();

		// Setup arguments.
		$r = wp_parse_args(
			$args,
			[
				'plural'   => sugar_calendar_get_post_type_label( $pt, 'name', esc_html__( 'Events', 'sugar-calendar-lite' ) ),
				'singular' => sugar_calendar_get_post_type_label( $pt, 'singular_name', esc_html__( 'Event', 'sugar-calendar-lite' ) ),
			]
		);

		// Return arguments.
		return $r;
	}

	/**
	 * Force the order and orderby, so default view is correct.
	 *
	 * @since 2.0.0
	 */
	protected function init_globals() {

		$_GET['order']   = $this->get_order();
		$_GET['orderby'] = $this->get_orderby();
	}

	/**
	 * Set the time zone.
	 *
	 * @since 2.1.2
	 */
	protected function init_timezone() {

		$this->timezone = sugar_calendar_get_timezone();
	}

	/**
	 * Set the boundaries.
	 *
	 * @since 2.0.0
	 */
	protected function init_boundaries() {

		// Set now once, so everything uses the same timestamp.
		$this->now = $this->get_current_time();

		$now_date = new \DateTime( '@' . $this->now );
		$now_date->setTime( 0, 0, 0 );

		$this->today_first_second = $now_date->getTimestamp();

		// Set formatting options.
		$this->start_of_week = $this->get_start_of_week();
		$this->date_format   = $this->get_date_format();
		$this->time_format   = $this->get_time_format();

		// Set year, month, & day.
		$this->year  = $this->get_year();
		$this->month = $this->get_month();
		$this->day   = $this->get_day();

		// Set list-mode specific year.
		$this->start_year = $this->get_start_year();

		// Set "today" based on current request.
		$this->today = strtotime( "{$this->year}/{$this->month}/{$this->day}" );
	}

	/**
	 * Set the modes.
	 *
	 * @since 2.0.0
	 */
	protected function init_modes() {

		$this->modes = [
			'month' => esc_html__( 'Month', 'sugar-calendar-lite' ),
			'week'  => esc_html__( 'Week', 'sugar-calendar-lite' ),
			'day'   => esc_html__( 'Day', 'sugar-calendar-lite' ),
			'list'  => esc_html__( 'List', 'sugar-calendar-lite' ),
		];
	}

	/**
	 * Set the maximum number of items per iteration.
	 *
	 * @since 2.0.0
	 */
	protected function init_max() {

		$this->max = sugar_calendar_get_user_preference( 'events_max_num', 100 );
	}

	/**
	 * Set the days of the week.
	 *
	 * @since 2.0.0
	 */
	protected function init_week_days() {

		// Day values.
		$days = [
			'0' => 'sunday',
			'1' => 'monday',
			'2' => 'tuesday',
			'3' => 'wednesday',
			'4' => 'thursday',
			'5' => 'friday',
			'6' => 'saturday',
		];

		// Get the day index.
		$index  = array_search( $this->start_of_week, array_keys( $days ) );
		$start  = array_slice( $days, $index, count( $days ), true );
		$finish = array_slice( $days, 0, $index, true );

		// Set days for week.
		$this->week_days = $start + $finish;
	}

	/** Setters ***************************************************************/

	/**
	 * Set the start, end, and duration of the current view.
	 *
	 * @since 2.0.0
	 *
	 * @param string $start Start of the view.
	 * @param string $end   End of the view.
	 */
	protected function set_view( $start = '', $end = '' ) {

		// Convert to timestamps.
		$start_time = strtotime( $start );
		$end_time   = strtotime( $end );

		// Set view boundaries.
		$this->view_start    = ( $end_time > $start_time ) ? $start : $end;
		$this->view_end      = ( $end_time < $start_time ) ? $start : $end;
		$this->view_duration = ( $end_time - $start_time );

		// Set view time zone.
		$this->view_timezone = sugar_calendar_get_timezone_object( $this->timezone );
	}

	/**
	 * Set the filtered items.
	 *
	 * @since 2.1.2
	 */
	protected function set_filtered_items() {

		// Get the filter.
		$filter = $this->get_items_filter();

		// No queried items.
		if ( empty( $this->query->items ) ) {
			$this->filtered_items = [];

			// No filter ("All" for view).
		} elseif ( empty( $filter ) ) {
			// Exclude trashed events.
			$this->filtered_items = array_filter(
				$this->query->items,
				fn( $event ) => in_array( $event->status, array_keys( get_post_statuses() ) )
			);

			// Filter queried items.
		} else {
			$this->filtered_items = wp_list_filter( $this->query->items, $filter );
		}

		// Set the filtered IDs.
		$this->filtered_ids = wp_list_pluck( $this->filtered_items, 'id' );

		/**
		 * Filter the filtered ids to be displayed in the admin events tables.
		 *
		 * @since 3.6.0
		 *
		 * @param array  $filtered_ids The filtered ids to be displayed in the admin events tables.
		 * @param string $view_start   The beginning boundary for the current view.
		 * @param string $view_end     The end boundary for the current view.
		 * @param string $status       The status of the events to filter.
		 */
		$this->filtered_ids = apply_filters(
			'sugar_calendar_admin_events_tables_base_filtered_ids',
			$this->filtered_ids,
			$this->view_start,
			$this->view_end,
			$this->get_status()
		);
	}

	/**
	 * Set all of the items to loop through.
	 *
	 * @since 2.2.0
	 */
	protected function set_all_items() {

		// No items, so skip some processing.
		if ( empty( $this->query->items ) ) {
			$this->all_items = [];

			// Get sequences in this range from queried items.
		} else {

			// Environment.
			$sow = sugar_calendar_daynum_to_ical( $this->start_of_week );

			// Range.
			$after  = sugar_calendar_get_datetime_object( $this->view_start, $this->view_timezone );
			$before = sugar_calendar_get_datetime_object( $this->view_end, $this->view_timezone );

			// Get all of the items.
			$this->all_items = sugar_calendar_get_event_sequences(
				$this->query->items,
				$after,
				$before,
				$this->view_timezone, // Already an object.
				$sow
			);
		}

		/**
		 * Filter the all items to be displayed in the admin events tables.
		 *
		 * @since 3.6.0
		 *
		 * @param array  $all_items  The items to be displayed in the admin events tables.
		 * @param string $view_start The beginning boundary for the current view.
		 * @param string $view_end   The end boundary for the current view.
		 * @param string $status     The status of the events to filter.
		 */
		$this->all_items = apply_filters(
			'sugar_calendar_admin_events_tables_base_all_items',
			$this->all_items,
			$this->view_start,
			$this->view_end,
			$this->get_status()
		);
	}

	/**
	 * Set the item counts for the current view.
	 *
	 * @since 2.1.6
	 * @since 3.6.0 Added the filter `sugar_calendar_admin_events_tables_base_item_counts`.
	 */
	protected function set_item_counts() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.NestingLevel.MaxExceeded

		// Reset.
		$this->item_counts = [
			'total' => 0,
		];

		// Bail if no queried items or no cells.
		if ( empty( $this->cells ) ) {
			return;
		}

		// Default counts.
		$counts = [];

		// Get all items from all cells.
		$countable_items = wp_list_pluck( $this->cells, 'countable' );

		// Bail if no cell items.
		if ( empty( $countable_items ) ) {
			return;
		}

		// Loop through cell items and flatten.
		foreach ( $countable_items as $cell_items ) {

			// Skip if no items in cell.
			if ( empty( $cell_items ) ) {
				continue;
			}

			// Remove empty.
			$counts = array_merge( $counts, $cell_items );
		}

		// Default all items.
		$countables = [];

		// Reduce counts down to countables by ID.
		foreach ( $counts as $countable ) {
			if ( ! isset( $countables[ $countable->id ] ) ) {
				$countables[ $countable->id ] = $countable;
			}
		}

		// Unique items.
		$all_items = array_unique( $countables, SORT_REGULAR );

		// Pluck all queried statuses.
		$statuses = wp_list_pluck( $all_items, 'status' );

		// Get unique statuses only.
		$statuses = array_unique( $statuses );

		$unique_events = [];

		// Loop through statuses.
		if ( ! empty( $statuses ) ) {
			foreach ( $statuses as $status ) {

				// Get items of this status.
				$status_items = wp_filter_object_list(
					$all_items,
					[
						'status' => $status,
					]
				);

				$counter = 0;

				// Loop through each of the status items.
				foreach ( $status_items as $item ) {
					if ( ! in_array( $item->object_id, $unique_events, true ) ) {
						$unique_events[] = $item->object_id;

						++$counter;
					}
				}

				// Add count to return value.
				$this->item_counts[ $status ] = $counter;
			}
		}

		// Set total to count of all items.
		$this->item_counts['total'] = count( $this->all_items );

		/**
		 * Filter the item counts for the current view.
		 *
		 * @since 3.6.0
		 *
		 * @param array  $item_counts The item counts for the current view.
		 * @param string $view_start  The beginning boundary for the current view.
		 * @param string $view_end    The end boundary for the current view.
		 * @param string $status      The status of the events to filter.
		 */
		$this->item_counts = apply_filters(
			'sugar_calendar_admin_events_tables_base_item_counts',
			$this->item_counts,
			$this->view_start,
			$this->view_end,
			$this->get_status()
		);
	}

	/**
	 * Import object variables from another object.
	 *
	 * @since 2.0.0
	 *
	 * @param object $item List table to import variables from.
	 */
	protected function set_list_table( $item = false ) {

		global $wp_list_table;

		// Bail if no object passed.
		if ( empty( $item ) ) {
			return;
		}

		// Set the old list table.
		$this->old_list_table = $item;

		// Loop through object vars and set the key/value.
		foreach ( get_object_vars( $item ) as $key => $value ) {
			if ( ! isset( $this->{$key} ) ) {
				$this->{$key} = $value;
			}
		}

		// Set the global list table to this class.
		$wp_list_table = $this; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
	}

	/** Getters ***************************************************************/

	/**
	 * Return the post type of the current screen.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_screen_post_type() {

		return ! empty( $this->screen->post_type )
			? $this->screen->post_type
			: '';
	}

	/**
	 * Return the page of the current screen.
	 *
	 * @since 2.0.0
	 */
	protected function get_page() {

		return 'sugar-calendar';
	}

	/**
	 * Return the primary post type.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_primary_post_type() {

		return sugar_calendar_get_event_post_type_id();
	}

	/**
	 * Return the base URL.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_base_url() {

		return sugar_calendar_get_admin_base_url();
	}

	/**
	 * Return array of filters used on queried items.
	 *
	 * @since 2.1.2
	 *
	 * @return array
	 */
	protected function get_items_filter() {

		// Get the status.
		$status = $this->get_status();

		// Bail if viewing all.
		if ( $status === 'all' ) {
			return [];
		}

		// Return filter by status.
		return [
			'status' => $status,
		];
	}

	/**
	 * Return a properly formatted, multi-dimensional array of event counts,
	 * grouped by status.
	 *
	 * @since 2.0.0
	 * @since 2.1.6 $item_counts is populated by set_item_counts()
	 *
	 * @return array
	 */
	protected function get_item_counts() {

		return $this->item_counts;
	}

	/**
	 * Return array of intervals.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_recurrence_types() {

		return sugar_calendar_get_recurrence_types();
	}

	/**
	 * Return array of date query arguments, used for `date_query` parameter.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_date_query_args() {

		return sugar_calendar_get_date_query_args( $this->get_mode(), $this->view_start, $this->view_end );
	}

	/**
	 * Return tag IDs.
	 *
	 * @since 3.7.0
	 *
	 * @return array
	 */
	protected function get_tags() {

		$tags_value = $this->get_request_var( 'sc_event_tags' );

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

		return TagsHelper::validate_tags_term_ids( explode( ',', $tags_value ) );
	}

	/**
	 * Return a human-readable time difference as a string.
	 *
	 * @since 2.0.0
	 *
	 * @param int|string $older_date The earlier time from which you're calculating
	 *                               the time elapsed. Enter either as an integer Unix timestamp,
	 *                               or as a date string of the format 'Y-m-d H:i:s'.
	 * @param int|bool   $newer_date Optional. Unix timestamp of date to compare older
	 *                               date to. Default: false (current time).
	 *
	 * @return string String representing the time since the older date, eg
	 *         "2 hours and 50 minutes".
	 */
	protected function get_human_diff_time( $older_date, $newer_date = false ) {

		return sugar_calendar_human_diff_time( $older_date, $newer_date );
	}

	/**
	 * Return a human-readable time difference as a string.
	 *
	 * @since 2.1.0
	 *
	 * @param string $timezone1 First Olson time zone ID.
	 * @param string $timezone2 Optional. Default: 'UTC' Second Olson time zone ID.
	 * @param mixed  $datetime  Optional. Default: 'now' Time to use for diff.
	 *
	 * @return string String representing the time difference - "2.5 hours"
	 */
	protected function get_human_diff_timezone( $timezone1 = '', $timezone2 = 'UTC', $datetime = 'now' ) {

		return sugar_calendar_human_diff_timezone( $timezone1, $timezone2, $datetime );
	}

	/**
	 * Return the color of an event.
	 *
	 * @since 2.0.0
	 *
	 * @param object $object Event object.
	 *
	 * @return string
	 */
	protected function get_item_color( $object ) {

		return sugar_calendar_get_event_color( $object->object_id, $object->type );
	}

	/**
	 * Get the current time.
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 */
	protected function get_current_time() {

		return sugar_calendar_get_request_time( 'timestamp', $this->timezone );
	}

	/**
	 * Get the day each week starts on.
	 *
	 * @since 2.0.0
	 *
	 * @param int|string $start Day the week starts on.
	 *
	 * @return string
	 */
	protected function get_start_of_week( $start = '1' ) {

		return (string) sugar_calendar_get_user_preference( 'start_of_week', (string) $start );
	}

	/**
	 * Get the ISO-8601 week number for a Unix timestamp.
	 *
	 * @since 2.3.0
	 *
	 * @param int $timestamp Timestamp.
	 *
	 * @return string
	 */
	protected function get_week_for_timestamp( $timestamp = 0 ) {

		return gmdate( 'W', strtotime( 'this thursday', (int) $timestamp ) );
	}

	/**
	 * Get the date format.
	 *
	 * @since 2.0.0
	 *
	 * @param string $format Date format.
	 *
	 * @return string
	 */
	protected function get_date_format( $format = 'F j, Y' ) {

		return sugar_calendar_get_user_preference( 'date_format', $format );
	}

	/**
	 * Get the time format.
	 *
	 * @since 2.0.0
	 *
	 * @param string $format Time format.
	 *
	 * @return string
	 */
	protected function get_time_format( $format = 'g:i a' ) {

		return sugar_calendar_get_user_preference( 'time_format', $format );
	}

	/**
	 * Get the URL with persistent arguments.
	 *
	 * @since 2.0.0
	 *
	 * @param array $args Array of arguments to override.
	 *
	 * @return string
	 */
	protected function get_persistent_url( $args = [] ) {

		// Get tax terms.
		$tax_terms = $this->get_tax_terms();

		// Query arg defaults.
		$defaults = [
			'page'        => $this->get_page(),
			'cystart'     => $this->get_start_year(),
			'cy'          => $this->get_year(),
			'cm'          => $this->get_month(),
			'cd'          => $this->get_day(),
			'cz'          => $this->get_timezone(),
			'mode'        => $this->get_mode(),
			'max'         => $this->get_max(),
			'status'      => $this->get_status(),
			'object_type' => $this->get_object_type(),
			's'           => $this->get_search(),
		];

		// Parse arguments.
		$r = wp_parse_args( $args, array_merge( $defaults, $tax_terms ) );

		// Maybe unset default status.
		if ( $r['status'] === 'all' ) {
			unset( $r['status'] );
		}

		// Maybe unset default object type.
		if ( $r['object_type'] === 'post' ) {
			unset( $r['object_type'] );
		}

		// Maybe unset default search.
		if ( empty( $r['s'] ) ) {
			unset( $r['s'] );
		}

		// Maybe unset default time zone.
		if ( empty( $r['cz'] ) || ( $this->timezone === $r['cz'] ) ) {
			unset( $r['cz'] );
		}

		// Maybe unset list-years.
		if ( $r['mode'] !== 'list' ) {
			unset( $r['cystart'] );
		}

		// Use the base URL.
		$url = $this->get_base_url();

		// Add args & return.
		return add_query_arg( $r, $url );
	}

	/**
	 * Get the URL for today.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_today_url() {

		return $this->get_persistent_url(
			[
				'cy' => gmdate( 'Y', $this->now ),
				'cm' => gmdate( 'n', $this->now ),
				'cd' => gmdate( 'j', $this->now ),
			]
		);
	}

	/**
	 * Setup the list-table columns.
	 *
	 * @since 2.0.0
	 *
	 * @return array An associative array containing column information.
	 */
	public function get_columns() {

		static $retval = null;

		// Calculate if not calculated already.
		if ( $retval === null ) {

			// PHP day => day ID.
			$days = $this->get_week_days();

			// Setup return value.
			$retval = [];

			foreach ( $days as $key => $day ) {
				$retval[ $day ] = $GLOBALS['wp_locale']->get_weekday( $key );
			}
		}

		return $retval;
	}

	/**
	 * Get the days for any given week.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_week_days() {

		return $this->week_days;
	}

	/**
	 * Get the day-of-week ordinal.
	 *
	 * @since 2.2.0
	 *
	 * @param int $day Day of month.
	 *
	 * @return string
	 */
	protected function get_dow_ordinal( $day = 1 ) {

		// Default return value.
		$retval = '';

		// Possible day ordinals (no more than 5 per month).
		$ordinals = [
			1 => 'first',
			2 => 'second',
			3 => 'third',
			4 => 'fourth',
			5 => 'fifth',
		];

		// Get the ordinal of the day.
		$dow_ordinal = (int) ceil( $day / 7 );

		// Maybe set the return value.
		if ( ! empty( $ordinals[ $dow_ordinal ] ) ) {
			$retval = $ordinals[ $dow_ordinal ];
		}

		// Return.
		return $retval;
	}

	/**
	 * No columns are sortable.
	 *
	 * @since 2.0.0
	 *
	 * @return array An associative array containing the sortable columns
	 */
	protected function get_sortable_columns() {

		return [];
	}

	/**
	 * No columns are hidden.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_hidden_columns() {

		// Get hidden columns for screen.
		$screen  = get_current_screen();
		$columns = get_hidden_columns( $screen );

		// Return any hidden columns.
		return $columns;
	}

	/**
	 * No bulk actions.
	 *
	 * @since 2.0.0
	 *
	 * @return array An associative array containing all the bulk actions.
	 */
	public function get_bulk_actions() {

		return [];
	}

	/**
	 * Get the possible list table modes.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_modes() {

		return $this->modes;
	}

	/**
	 * Get the current mode.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_mode() {

		return $this->mode;
	}

	/**
	 * Get the current object type.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_object_type() {

		return $this->get_request_var( 'object_type', 'sanitize_key', 'post' );
	}

	/**
	 * Get the current month.
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 */
	protected function get_month() {

		$default = gmdate( 'n', $this->now );

		return $this->get_request_var( 'cm', 'intval', $default );
	}

	/**
	 * Get the current day.
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 */
	protected function get_day() {

		$default = gmdate( 'j', $this->now );

		return $this->get_request_var( 'cd', 'intval', $default );
	}

	/**
	 * Get the current year.
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 */
	protected function get_year() {

		$default = gmdate( 'Y', $this->now );

		return $this->get_request_var( 'cy', 'intval', $default );
	}

	/**
	 * Get the requested start year for the list boundary.
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 */
	protected function get_start_year() {

		$default = gmdate( 'Y', $this->now );

		return $this->get_request_var( 'cystart', 'intval', $default );
	}

	/**
	 * Get the current time zone.
	 *
	 * @since 2.1.0
	 *
	 * @return string
	 */
	protected function get_timezone() {

		$default = $this->timezone;

		return $this->get_request_var( 'cz', 'urldecode', $default );
	}

	/**
	 * Get the current event status.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_status() {

		return $this->get_request_var( 'status', 'sanitize_key', 'all' );
	}

	/**
	 * Get the current term for a taxonomy.
	 *
	 * @since 2.0.0
	 * @since 3.8.2 Add custom sanitization for tags.
	 *
	 * @param string $taxonomy_name Taxonomy name.
	 * @param string $default       Default name.
	 *
	 * @return mixed
	 */
	protected function get_tax_term( $taxonomy_name = '', $default = '' ) {

		$sanitize_function = 'sanitize_key';

		if ( $taxonomy_name === TagsHelper::get_tags_taxonomy_id() ) {
			$sanitize_function = 'sanitize_text_field';
		}

		return $this->get_request_var( $taxonomy_name, $sanitize_function, $default );
	}

	/**
	 * Get taxonomy term requests.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_tax_terms() {

		// Default return value.
		$retval = [];

		// Get the taxonomies.
		$taxonomies = sugar_calendar_get_object_taxonomies(
			$this->get_primary_post_type()
		);

		// Maybe add taxonomies to tabs array.
		if ( empty( $taxonomies ) ) {
			return $retval;
		}

		// Loop through each taxonomy.
		foreach ( $taxonomies as $tax ) {

			// Look for term lookup.
			$term = $this->get_tax_term( $tax );

			// Skip if no request.
			if ( empty( $term ) ) {
				continue;
			}

			// Set.
			$retval[ $tax ] = $term;
		}

		// Return any taxonomy requests.
		return $retval;
	}

	/**
	 * Get the current page number.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_orderby() {

		return $this->get_request_var( 'orderby', 'sanitize_key', 'start' );
	}

	/**
	 * Get the current page number.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_order() {

		return $this->get_request_var( 'order', 'strtolower', 'asc' );
	}

	/**
	 * Get the current page number.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_search() {

		return $this->get_request_var( 's', 'wp_unslash' );
	}

	/**
	 * Get the maximum number of events per iteration.
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 */
	protected function get_max() {

		return $this->get_request_var( 'max', 'absint', $this->max );
	}

	/**
	 * Get a global request variable.
	 *
	 * @since 2.0.0
	 * @since 3.3.0 Update return value to mixed.
	 *
	 * @param string $var      Variable name.
	 * @param string $sanitize Sanitiazion function.
	 * @param string $default  Default value.
	 *
	 * @return mixed
	 */
	protected function get_request_var( $var = '', $sanitize = 'sanitize_text_field', $default = '' ) {

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		return isset( $_REQUEST[ $var ] )
			? call_user_func( $sanitize, $_REQUEST[ $var ] )
			: $default;
		// phpcs:enable WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
	}

	/**
	 * Get available statuses.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_supported_post_stati() {

		return [
			'publish',
			'future',
			'draft',
			'pending',
			'private',
			'hidden',
			'trash',
		];
	}

	/**
	 * Get removable query arguments.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_removable_args() {

		return [
			'filter_action',
			's',
			'order',
			'orderby',
			'status',
			'mode',
		];
	}

	/**
	 * Get a list of CSS classes for the list table table tag.
	 *
	 * @since 2.0.0
	 *
	 * @return array List of CSS classes for the table tag.
	 */
	protected function get_table_classes() {

		return [
			'widefat',
			'fixed',
			'striped',
			'calendar',
			'sugar-calendar-table',
			'sugar-calendar-table-events',
			'sugar-calendar-table-events--' . $this->get_mode(),
			$this->get_mode(),
			$this->get_status(),
			$this->_args['plural'],
		];
	}

	/**
	 * Get the calendar views.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_views() {

		// Output the event filter above the views.
		$this->event_search();

		// Screen.
		$base_url = $this->get_persistent_url();

		// Get the event counts for the current view.
		$event_counts = $this->get_item_counts();

		// Statuses.
		$avail_post_stati = $this->get_supported_post_stati();
		$event_statuses   = get_post_stati( [ 'show_in_admin_all_list' => false ] );

		// "All" link class.
		$all_class = ( $this->get_status() === 'all' )
			? 'current'
			: '';

		// "All" link text.
		$all_inner_html = sprintf( /* translators: %s - Item count. */
			_nx(
				'All <span class="count">(%s)</span>',
				'All <span class="count">(%s)</span>',
				$event_counts['total'],
				'List table: all statuses (excluding trash)',
				'sugar-calendar-lite'
			),
			number_format_i18n( $event_counts['total'] )
		);

		// "All" link URL.
		$all_url = remove_query_arg( 'status', $base_url );

		// Setup status links.
		$status_links = [
			'all' => '<a href="' . esc_url( $all_url ) . '" class="' . esc_attr( $all_class ) . '">' . $all_inner_html . '</a>',
		];

		// Other links.
		$event_statuses = get_post_stati( [ 'show_in_admin_status_list' => true ], 'objects' );

		// Loop through statuses and compile array of available ones.
		if ( ! empty( $event_statuses ) ) {
			foreach ( $event_statuses as $status ) {

				// Set variable to trick PHP.
				$status_name = $status->name;

				// Skip if not available status.
				if ( ! in_array( $status_name, $avail_post_stati, true ) ) {
					continue;
				}

				// Skip if no event count.
				if ( empty( $event_counts[ $status_name ] ) ) {
					continue;
				}

				// Set the class value.
				if ( $this->get_status() === $status_name ) {
					$class = 'current';
				} else {
					$class = '';
				}

				// Calculate the status text.
				$status_html = sprintf( translate_nooped_plural( $status->label_count, $event_counts[ $status_name ] ), number_format_i18n( $event_counts[ $status_name ] ) );
				$status_url  = add_query_arg( [ 'status' => $status_name ], $base_url );

				// Add link to array.
				$status_links[ $status_name ] = '<a href="' . esc_url( $status_url ) . '" class="' . esc_attr( $class ) . '">' . $status_html . '</a>';
			}
		}

		// Return array of HTML anchors.
		return $status_links;
	}

	/**
	 * Get the query arguments used to get events from the database.
	 *
	 * This is split up into a few separate methods to make overriding
	 * individual query arguments easier.
	 *
	 * @since 2.0.0
	 * @since 3.6.0 Add the 'object_subtype' arg.
	 *
	 * @param array $args Query arguments.
	 *
	 * @return array
	 */
	protected function all_query_args( $args = [] ) {

		// Maybe add `post` to to object_type's to query for.
		if ( post_type_supports( $this->get_screen_post_type(), 'events' ) ) {
			$args['object_type'] = ! empty( $args['object_type'] )
				? array_unshift( $args['object_type'], 'post' )
				: [ 'post' ];
		}

		// Setup default args.
		$defaults = [
			'number'         => $this->get_max(),
			'orderby'        => $this->get_orderby(),
			'order'          => $this->get_order(),
			'search'         => $this->get_search(),
			'date_query'     => $this->get_date_query_args(),
			'object_subtype' => sugar_calendar_get_event_post_type_id(),
		];

		// Parse the arguments.
		$r = wp_parse_args( $args, $defaults );

		// Check tags query var.
		$tags = $this->get_tags();

		// Add tags to query args.
		if ( ! empty( $tags ) ) {
			$r['sc_event_tags'] = $tags;
		}

		// Return parsed arguments.
		return $r;
	}

	/**
	 * Set a queried item in its proper array position.
	 *
	 * @since 2.0.0
	 *
	 * @param int    $cell    Cell index.
	 * @param string $type    Cell item key.
	 * @param int    $item_id Cell item ID.
	 * @param mixed  $data    Cell item data.
	 */
	protected function set_queried_item( $cell = 1, $type = 'items', $item_id = 0, $data = [] ) {

		// Prevent debug notices if type is not set.
		if ( ! isset( $this->cells[ $cell ][ $type ] ) ) {
			$this->cells[ $cell ][ $type ] = [];
		}

		// Set the queried item.
		$this->cells[ $cell ][ $type ][ $item_id ] = $data;
	}

	/**
	 * Get the already queried items for a given day.
	 *
	 * @since 2.0.0
	 *
	 * @param int    $cell Cell index.
	 * @param string $type Cell item type.
	 *
	 * @return array
	 */
	protected function get_queried_items( $cell = 1, $type = 'items' ) {

		// Bail if no cells or queried items.
		if ( empty( $this->cells ) || empty( $this->cells[ $cell ] ) || empty( $this->cells[ $cell ][ $type ] ) ) {
			return [];
		}

		// Return queried items.
		return $this->cells[ $cell ][ $type ];
	}

	/**
	 * Maybe skip an item in a cell.
	 *
	 * @since 2.0.0
	 *
	 * @param object $item Cell item.
	 *
	 * @return bool
	 */
	protected function skip_item_in_cell( $item = false ) {

		return empty( $item );
	}

	/**
	 * Does an event belong inside the current cell?
	 *
	 * @since 2.0.0
	 * @since 2.1.2 Prefers Event::intersects() over Event::overlaps()
	 * @since 3.6.0 Fix issue with events extending one hour in week and day view.
	 *
	 * @param object $item Cell item.
	 *
	 * @return bool
	 */
	protected function is_item_for_cell( $item = false ) {

		// Bail if skipping.
		if ( $this->skip_item_in_cell( $item ) ) {
			return false;
		}

		// Start boundary.
		$start = $this->get_current_cell( 'start_dto' );

		if ( in_array( $this->mode, [ 'week', 'day' ], true ) && $start instanceof \DateTime ) {
			$start->modify( '+1 second' );
		}

		// End boundary.
		$end = $this->get_current_cell( 'end_dto' );

		// Get intersects.
		$retval = $item->intersects( $start, $end );

		// Return if event belongs in cell.
		return $retval;
	}

	/**
	 * Get events for a given cell.
	 *
	 * @since 2.0.0
	 * @since 3.8.0 Sort the events in the cell by ascending order.
	 *
	 * @return string
	 */
	protected function get_events_for_cell() {

		// Default return value.
		$retval = '';

		// Default items array.
		$items = $this->get_current_cell( 'items', [] );

		// Bail if no items.
		if ( empty( $items ) ) {
			return $retval;
		}

		// Sort items by start_dto in ascending order.
		usort(
			$items,
			function ( $a, $b ) {
				if ( ! isset( $a->start_dto ) || ! isset( $b->start_dto ) ) {
					return 0;
				}

				// Compare DateTime objects.
				return $a->start_dto <=> $b->start_dto;
			}
		);

		// Loop through today's events.
		foreach ( $items as $item ) {
			$retval .= $this->get_event( $item );
		}

		// Return the output buffer.
		return $retval;
	}

	/**
	 * Get number of events in a specific cell.
	 *
	 * @since 2.0.0
	 *
	 * @param int    $cell Cell index.
	 * @param string $type Cell item type.
	 *
	 * @return int
	 */
	protected function get_event_count_for_cell( $cell = 1, $type = 'items' ) {

		$events = $this->get_queried_items( $cell, $type );

		// Return 0 or number.
		return ! empty( $events )
			? count( $events )
			: 0;
	}

	/**
	 * Get an event link for use inside a table cell.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_event( $event = false ) {

		// Bail if event is empty.
		if ( empty( $event ) ) {
			return '';
		}

		// Get the link.
		$link = $this->get_event_link( $event );

		// Return the event link.
		return $link;
	}

	/**
	 * Return the string for the event title.
	 *
	 * Based on _draft_or_post_title() but is filtered, and not escaped.
	 *
	 * @since 2.1.7
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_event_title( $event = false ) {

		$title = ! empty( $event->title )
			? apply_filters( 'the_title', $event->title, $event->object_id ) // phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation,WPForms.PHP.ValidateHooks.InvalidHookName
			: esc_html__( '(No title)', 'sugar-calendar-lite' );

		if ( $event->status === 'draft' ) {
			$title .= ' &mdash; ' . esc_html__( 'Draft', 'sugar-calendar-lite' );
		}

		return $title;
	}

	/**
	 * Return the HTML for linking to an event.
	 *
	 * @since 2.0.3
	 * @since 3.7.0 Added the `sugar_calendar_admin_events_table_event_title_url` filter.
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_event_link( $event = false ) {

		// Bail if event is empty.
		if ( empty( $event ) ) {
			return '';
		}

		// Get the cell.
		$cell = $this->get_current_cell( 'index' );

		/**
		 * Filter the event edit url.
		 *
		 * @since 3.7.0
		 *
		 * @param string $url   The event edit url.
		 * @param Event  $event The event object.
		 */
		$event_edit_url = apply_filters(
			'sugar_calendar_admin_events_table_event_title_url',
			$this->get_event_edit_url( $event ),
			$event
		);

		// Get the event title.
		$event_title = $this->get_event_title( $event );

		// Filter all event attributes.
		$attributes = [
			'href'    => esc_url( $event_edit_url ),
			'class'   => $this->get_event_classes( $event, $cell ),
			'style'   => $this->get_event_link_styling( $event ),
			'data-id' => $event->id,
		];

		// Default attribute string.
		$attr = '';

		// Loop through attributes and combine them (previously sanitized).
		foreach ( $attributes as $key => $value ) {
			$attr .= ' ' . $key . '="' . $value . '"';
		}

		// Setup the pointer for this event.
		$this->setup_pointer( $event, $cell );

		// Prepare the link HTML.
		$html = '<a %s><div class="sce__admin__events-table__event-entry__cal-indicator"></div><span>%s</span></a>';
		$link = sprintf( $html, $attr, esc_html( $event_title ) );

		// Return the event link.
		return $link;
	}

	/**
	 * Get event link styles.
	 *
	 * @since 2.0.3
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_event_link_styling( $event = false ) {

		$bg_color = $this->get_item_color( $event );

		if ( ! $bg_color ) {
			return '';
		}

		$color    = sugar_calendar_get_contrast_color( $bg_color );
		$css_vars = '--sugar-calendar-background-color: ' . $bg_color . '; --sugar-calendar-foreground-color: ' . $color . ';';

		return $css_vars;
	}

	/** Cell ******************************************************************/

	/**
	 * Whether to start a new row in the table.
	 *
	 * @since 2.0.0
	 *
	 * @return bool
	 */
	protected function start_row() {

		$i = (int) $this->get_current_cell( 'index' );

		return ( $i % $this->day_count ) === 0;
	}

	/**
	 * Whether to end the current row in the table.
	 *
	 * @since 2.0.0
	 *
	 * @return bool
	 */
	protected function end_row() {

		$i = (int) $this->get_current_cell( 'index' );

		return ( $i % $this->day_count ) === ( $this->day_count - 1 );
	}

	/**
	 * Set the items for the current cell.
	 *
	 * @since 2.1.3
	 */
	protected function set_cell_items() {

		// Loop through all items.
		foreach ( $this->all_items as $item ) {

			// Skip if event is not for cell.
			if ( ! $this->is_item_for_cell( $item ) ) {
				continue;
			}

			// Filtered items only.
			if ( in_array( $item->id, $this->filtered_ids, true ) ) {
				array_push( $this->current_cell['items'], $item );
			}

			// Count all items (reduced later).
			array_push( $this->current_cell['countable'], $item );
		}

		// Add the current cell to the cells array.
		$this->cells[] = $this->current_cell;
	}

	/**
	 * Set the items for all of the cells.
	 *
	 * @since 2.1.3
	 */
	protected function set_cells() {

		$row   = 0;
		$index = 0;

		// Loop through days in the grid.
		for ( $d = $this->grid_start; $d < $this->grid_end; $d += DAY_IN_SECONDS ) {

			$year  = gmdate( 'Y', $d );
			$month = gmdate( 'n', $d );
			$day   = gmdate( 'j', $d );

			// Setup cell boundaries.
			$this->set_cell_boundaries(
				[
					'start' => gmmktime( 0, 0, 0, $month, $day, $year ),
					'end'   => gmmktime( 23, 59, 59, $month, $day, $year ),
					'row'   => $row,
					'index' => $index,
				]
			);

			// Setup cell items.
			$this->set_cell_items();

			// Maybe end the row?
			if ( $this->end_row() ) {
				++$row;
			}

			$index++;
		}

		// Cleanup.
		$this->current_cell = [];
	}

	/**
	 * Set the current cell properties.
	 *
	 * @since 2.1.3
	 *
	 * @param array $args Cell properties.
	 */
	protected function set_cell_boundaries( $args = [] ) {

		// Parse arguments.
		$r = wp_parse_args(
			$args,
			[
				'index'     => null,
				'start'     => null,
				'end'       => null,
				'type'      => 'normal',
				'items'     => [],
				'countable' => [],
			]
		);

		// Get the time zone.
		$timezone = $this->get_timezone();

		// Add DateTime object for start.
		if ( ! empty( $r['start'] ) ) {
			$r['start_dto'] = sugar_calendar_get_datetime_object( $r['start'], $timezone );
		}

		// Add DateTime object for end.
		if ( ! empty( $r['end'] ) ) {
			$r['end_dto'] = sugar_calendar_get_datetime_object( $r['end'], $timezone );
		}

		// Set the current cell.
		$this->current_cell = $r;
	}

	/**
	 * Set the current cell using the $cells index.
	 *
	 * @since 2.1.3
	 *
	 * @param array $args Current cell arguments.
	 */
	protected function set_current_cell( $args = [] ) {

		// Get all matching cells.
		$cells = wp_list_filter( $this->cells, $args );

		// Pick the first matching cell.
		$this->current_cell = ! empty( $cells )
			? reset( $cells )
			: [];
	}

	/**
	 * Get the current cell properties.
	 *
	 * @since 2.0.0
	 *
	 * @param string $key     Cell item key.
	 * @param mixed  $default Default value.
	 *
	 * @return mixed
	 */
	protected function get_current_cell( $key = '', $default = null ) {

		// Return a specific cell key.
		if ( ! empty( $key ) && isset( $this->current_cell[ $key ] ) ) {
			return ! is_null( $this->current_cell[ $key ] )
				? $this->current_cell[ $key ]
				: $default;
		}

		// Return the entire array, or default return value.
		return is_null( $default )
			? $this->current_cell
			: $default;
	}

	/** Formatting ************************************************************/

	/**
	 * Get the date of the event.
	 *
	 * @since 2.0.0
	 *
	 * @param string $datetime Event datetime.
	 * @param string $timezone Event timezone.
	 *
	 * @return string
	 */
	protected function get_event_date( $datetime = '', $timezone = '' ) {

		return sugar_calendar_format_date_i18n( $this->date_format, $datetime, $timezone, $this->timezone );
	}

	/**
	 * Get the time of the event.
	 *
	 * @since 2.0.0
	 *
	 * @param string $datetime Event datetime.
	 * @param string $timezone Event timezone.
	 *
	 * @return string
	 */
	protected function get_event_time( $datetime = '', $timezone = '' ) {

		return sugar_calendar_format_date_i18n( $this->time_format, $datetime, $timezone, $this->timezone );
	}

	/**
	 * Get the time zone offset.
	 *
	 * @since 2.1.0
	 *
	 * @param array $args Timezone arguments.
	 *
	 * @return string
	 */
	protected function get_time_zone_offset( $args = [] ) {

		return sugar_calendar_get_timezone_offset( $args );
	}

	/** Pointers **************************************************************/

	/**
	 * Add an event to the pointers array.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 * @param int    $cell  Cell index.
	 */
	protected function setup_pointer( $event = false, $cell = false ) {

		// Bail if no event or no cell (0 is OK for cell).
		if ( empty( $event ) || ! is_numeric( $cell ) ) {
			return;
		}

		// Get pointer content HTML.
		$title = $this->get_pointer_title( $event );
		$text  = $this->get_pointer_text( $event );
		$links = $this->get_pointer_links( $event );

		ob_start();
		?>

		<div class="sugar_calendar_event_tooltip__header">
			<h3>
				<?php echo wp_kses( $title, $this->get_allowed_pointer_tags() ); ?>
			</h3>
		</div>
		<div class="sugar_calendar_event_tooltip__body">
			<?php echo wp_kses( $text, $this->get_allowed_pointer_tags() ); ?>
		</div>
		<div class="sugar_calendar_event_tooltip__footer">
			<?php echo wp_kses( implode( ' | ', $links ), $this->get_allowed_pointer_tags() ); ?>
		</div>

		<?php

		$pointer_content = ob_get_clean();

		/**
		 * Filter the event pointer content.
		 *
		 * @since 3.0.0
		 *
		 * @param string $pointer_content Pointer content.
		 * @param object $event           Event object.
		 * @param int    $cell            Cell index.
		 */
		$pointer_content = apply_filters( 'sugar_calendar_admin_events_tables_base_pointer_content', $pointer_content, $event, $cell );

		// Add pointer to pointers array.
		$this->pointers[ $event->id ] = $pointer_content;
	}

	/**
	 * Return the pointer title text.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_pointer_title( $event = false ) {

		// Get the event title.
		$title = $this->get_event_title( $event );

		// Default return value (text only).
		$retval = esc_js( $title );

		// Only link if not trashed.
		if ( $event->status !== 'trash' ) {

			// If user can edit, link to "edit object" page.
			if ( $this->current_user_can_edit( $event ) ) {
				$retval = $this->get_event_edit_link( $event, $retval );

				// If user can view, link to permalink.
			} elseif ( $this->current_user_can_view( $event ) ) {
				$retval = $this->get_event_view_link( $event, $retval );
			}
		}

		// Return.
		return $retval;
	}

	/**
	 * Return the pointer links HTML.
	 *
	 * @since 2.1.0
	 *
	 * @param object $event Event object.
	 *
	 * @return string[]
	 */
	protected function get_pointer_links( $event = false ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		// Default no links.
		$links = [];

		// Trashed, so maybe offer to Restore or Delete.
		if ( $event->status === 'trash' ) {

			// Maybe add restore link.
			if ( $this->current_user_can_delete( $event ) ) {
				$links['restore'] = $this->get_event_restore_link( $event, esc_html__( 'Restore', 'sugar-calendar-lite' ) );
			}

			// Maybe add delete link.
			if ( $this->current_user_can_delete( $event ) ) {
				$links['delete'] = $this->get_event_delete_link( $event, esc_html__( 'Delete Permanently', 'sugar-calendar-lite' ) );
			}

			// Not trashed, so offer to Edit or View.
		} else {

			// Maybe add edit & copy links.
			if ( $this->current_user_can_edit( $event ) ) {
				$links['edit'] = $this->get_event_edit_link( $event, esc_html_x( 'Edit', 'verb', 'sugar-calendar-lite' ) );
			}

			if ( $this->current_user_can_delete( $event ) ) {
				$links['delete'] = $this->get_event_delete_link( $event, esc_html_x( 'Trash', 'verb', 'sugar-calendar-lite' ) );
			}

			// Add view link.
			if ( $this->current_user_can_view( $event ) ) {
				$links['view'] = $this->get_event_view_link( $event, esc_html_x( 'View', 'verb', 'sugar-calendar-lite' ) );
			}
		}

		// Filter & return.
		return $this->filter_pointer_links( $links, $event );
	}

	/**
	 * Get the link used to edit an event.
	 *
	 * @since 2.0.0
	 * @since 3.6.0 Added the `sugar_calendar_admin_events_table_event_edit_link` filter.
	 *
	 * @param object $event     Event object.
	 * @param string $link_text Text of the link.
	 *
	 * @return string
	 */
	public function get_event_edit_link( $event = false, $link_text = '' ) {

		/**
		 * Allow filtering of the link text (e.g., preserve event title for recurrences).
		 *
		 * @since 3.8.2
		 *
		 * @param string $link_text Text of the link.
		 * @param Event  $event     Event object.
		 */
		$link_text = apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
			'sugar_calendar_admin_events_table_event_edit_link_text',
			$link_text,
			$event
		);

		/**
		 * Allow filtering of the link URL (e.g., preserve event title for recurrences).
		 *
		 * @since 3.8.2
		 *
		 * @param string $link_url  The event edit link.
		 * @param Event  $event     Event object.
		 */
		$link_url = apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
			'sugar_calendar_admin_events_table_event_edit_link_url',
			$this->get_event_edit_url( $event ),
			$event
		);

		/**
		 * Filter the event edit link.
		 *
		 * @since 3.6.0
		 *
		 * @param string $link  The event edit link.
		 * @param Event  $event The event object.
		 */
		return apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
			'sugar_calendar_admin_events_table_event_edit_link',
			'<a href="' . esc_url( $link_url ) . '">' . $link_text . '</a>',
			$event
		);
	}

	/**
	 * Get the link used to copy an event.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event     Event object.
	 * @param string $link_text Text of the link.
	 *
	 * @return string
	 */
	public function get_event_copy_link( $event = false, $link_text = '' ) {

		return '<a href="' . esc_url( $this->get_event_copy_url( $event ) ) . '">' . $link_text . '</a>';
	}

	/**
	 * Get the link used to delete an event.
	 *
	 * @since 2.0.21
	 * @since 3.6.0 Added the `sugar_calendar_admin_events_tables_base_event_delete_link` filter.
	 *
	 * @param object $event     Event object.
	 * @param string $link_text Text of the link.
	 *
	 * @return string
	 */
	public function get_event_delete_link( $event = false, $link_text = '' ) {

		/**
		 * Filter the event delete link.
		 *
		 * @since 3.6.0
		 *
		 * @param string $link  The event delete link.
		 * @param Event  $event The event object.
		 */
		return apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
			'sugar_calendar_admin_events_table_event_delete_link',
			'<a href="' . esc_url( $this->get_event_delete_url( $event ) ) . '" class="delete">' . $link_text . '</a>',
			$event
		);
	}

	/**
	 * Get the link used to restore an event.
	 *
	 * @since 2.0.21
	 * @since 3.6.0 Added the `sugar_calendar_admin_events_table_event_restore_link` filter.
	 *
	 * @param object $event     Event object.
	 * @param string $link_text Text of the link.
	 *
	 * @return string
	 */
	public function get_event_restore_link( $event = false, $link_text = '' ) {

		/**
		 * Filter the event delete link.
		 *
		 * @since 3.6.0
		 *
		 * @param string $link  The event restore link.
		 * @param Event  $event The event object.
		 */
		return apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
			'sugar_calendar_admin_events_table_event_restore_link',
			'<a href="' . esc_url( $this->get_event_restore_url( $event ) ) . '">' . $link_text . '</a>',
			$event
		);
	}

	/**
	 * Get the link used to view an event.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event     Event object.
	 * @param string $link_text Text of the link.
	 *
	 * @return string
	 */
	public function get_event_view_link( $event = false, $link_text = '' ) {

		return '<a href="' . esc_url( Helper::get_event_frontend_url( $event ) ) . '">' . $link_text . '</a>';
	}

	/**
	 * Get the URL used to edit an event.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_event_edit_url( $event = false ) {

		// Default return value.
		$retval = '';

		// Type of object.
		switch ( $event->object_type ) {
			case 'post':
				$retval = get_edit_post_link( $event->object_id );
				break;

			case 'user':
				$retval = get_edit_user_link( $event->object_id );
				break;

			case 'comment':
				$retval = get_edit_comment_link( $event->object_id );
				break;
		}

		// Return the HTML.
		return $retval;
	}

	/**
	 * Get the URL used to copy an event.
	 *
	 * @since 2.1.7
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_event_copy_url( $event = false ) {

		// Default return value.
		$retval = $this->get_event_edit_url( $event );

		// Fallback for post types where get_edit_post_link() returns null
		// (e.g. sc_recurring_event which has show_ui = false).
		if ( empty( $retval ) && $event->object_type === 'post' ) {
			$retval = admin_url( 'post.php?post=' . $event->object_id . '&action=edit' );
		}

		// Arguments.
		$action      = 'sc_copy';
		$request_uri = $_SERVER['REQUEST_URI'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$args        = [
			'action'          => $action,
			'wp_http_referer' => urlencode( wp_unslash( $request_uri ) ),
		];

		// URL.
		$url   = add_query_arg( $args, $retval );
		$nonce = "{$action}-{$event->object_type}_{$event->object_id}";

		// Return the URL.
		return wp_nonce_url( $url, $nonce );
	}

	/**
	 * Get the URL used to restore an event.
	 *
	 * @since 2.0.0
	 *
	 * @param Event $event Event object.
	 *
	 * @return string
	 */
	protected function get_event_delete_url( $event = false ) {

		// Default return value.
		$retval = $this->get_event_edit_url( $event );

		// Action.
		$action = ( $event->status !== 'trash' ) && EMPTY_TRASH_DAYS
			? 'trash'
			: 'delete';

		// Arguments.
		$request_uri = $_SERVER['REQUEST_URI'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$args        = [
			'action'          => $action,
			'wp_http_referer' => urlencode( wp_unslash( $request_uri ) ),
		];

		// URL.
		$url   = add_query_arg( $args, $retval );
		$nonce = "{$action}-{$event->object_type}_{$event->object_id}";

		// Return the URL.
		return wp_nonce_url( $url, $nonce );
	}

	/**
	 * Get the URL used to restore an event.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_event_restore_url( $event = false ) {

		// Default return value.
		$retval = $this->get_event_edit_url( $event );

		// Arguments.
		$request_uri = $_SERVER['REQUEST_URI'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$action      = 'untrash';
		$args        = [
			'action'          => $action,
			'wp_http_referer' => urlencode( wp_unslash( $request_uri ) ),
		];

		// URL.
		$url   = add_query_arg( $args, $retval );
		$nonce = "{$action}-{$event->object_type}_{$event->object_id}";

		// Return the URL.
		return wp_nonce_url( $url, $nonce );
	}

	/**
	 * Get all of the pointer text.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_pointer_text( $event = false ) {

		// Get all pointer info.
		$pointer = [
			$this->get_pointer_dates( $event ),
			$this->get_pointer_details( $event ),
		];

		$pointer[] = $this->get_pointer_for_tickets( $event );
		$pointer[] = $this->get_pointer_meta( $event );

		// Filter out empties and merge.
		$pointer_text = array_merge( array_filter( $pointer ) );

		// Remove HTML tags that are not allowed.
		foreach ( $pointer_text as $key => $value ) {
			$pointer_text[ $key ] = wp_kses( $value, $this->get_allowed_pointer_tags() );
		}

		/**
		 * Filter the event pointer text.
		 *
		 * @since 3.0.0
		 *
		 * @param string $pointer_text Pointer text.
		 * @param object $event        Event object.
		 */
		$retval = (array) apply_filters( 'sugar_calendar_admin_events_tables_base_get_pointer_text', $pointer_text, $event );

		// Combine with line breaks.
		return implode( '', $retval );
	}

	/**
	 * Get the pointer for tickets.
	 *
	 * @since 3.10.0
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	public function get_pointer_for_tickets( $event ) {

		/**
		 * Filter the pointer for tickets.
		 *
		 * @since 3.10.0
		 *
		 * @param bool                  $output Output.
		 * @param \Sugar_Calendar\Event $event  Event object.
		 */
		$output = apply_filters(
			'sugar_calendar_admin_events_tables_base_get_pointer_for_tickets',
			false,
			$event
		);

		if ( ! empty( $output ) ) {
			return $output;
		}

		$renderer   = new TicketingRenderer( $event );
		$extra_data = $renderer->get_extra_data();

		if ( empty( $extra_data ) ) {
			return false;
		}

		$attendees = isset( $extra_data['tickets_purchased'] ) ? absint( $extra_data['tickets_purchased'] ) : 0;

		return sprintf(
			'<strong>%1$s</strong><span>%2$s%3$s - <a target="_blank" href="%4$s">%5$s</a></span>',
			esc_html__( 'Attendees', 'sugar-calendar-lite' ),
			esc_html( $attendees ),
			$extra_data['ticket_total'] > 0 ? esc_html( ' / ' . $extra_data['ticket_total'] ) : '',
			esc_url( $extra_data['ticket_url'] ),
			esc_html__( 'View Tickets', 'sugar-calendar-lite' )
		);
	}

	/**
	 * Get the pointer details.
	 *
	 * @since 2.0.3
	 *
	 * @param object $event Event object.
	 *
	 * @return string
	 */
	protected function get_pointer_details( $event = false ) {

		$pointer_text = [];

		// Special case for password protected events.
		if ( ! empty( $event->post_password ) ) {
			$pointer_text['details_title'] = '<strong>' . esc_html__( 'Details', 'sugar-calendar-lite' ) . '</strong>';
			$pointer_text['details']       = '<span>' . esc_html__( 'Password protected', 'sugar-calendar-lite' ) . '</span>';

			// Post is not protected.
		} elseif ( ! empty( $event->content ) ) {

			// Trim content down to 25 words or less - no HTML, to be safe.
			$content = wp_trim_words( $event->content, 25 );

			// Title.
			$pointer_text['details_title'] = '<strong>' . esc_html__( 'Details', 'sugar-calendar-lite' ) . '</strong>';

			// Texturize.
			$pointer_text['details'] = '<span>' . esc_html( $content ) . '</span>';
		}

		/**
		 * Filter the event pointer details.
		 *
		 * @since 3.0.0
		 *
		 * @param string $pointer_text Pointer details.
		 * @param object $event        Event object.
		 */
		$retval = (array) apply_filters( 'sugar_calendar_admin_events_tables_base_get_pointer_details', $pointer_text, $event );

		// Separate with line breaks.
		return implode( '', $retval );
	}

	/**
	 * Get event dates for display in a pointer.
	 *
	 * @since 2.0.0
	 * @since 3.2.0 Corrected the start and end time labels for recurring events.
	 *
	 * @param Event $event Event object.
	 *
	 * @return array
	 */
	protected function get_pointer_dates( $event = false ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded,Generic.Metrics.NestingLevel.MaxExceeded

		$pointer_dates = [];

		// Default time zone offset strings.
		$stz = '';
		$etz = '';

		// Strip time zone formats from date & time formats.
		$df = $this->strip_timezone_format( $this->date_format );
		$tf = $this->strip_timezone_format( $this->time_format );

		// Start time zone.
		if ( ! empty( $event->start_tz ) ) {

			// Maybe show the original date, time, and zone.
			if ( ! empty( $this->timezone ) && ( $this->timezone !== $event->start_tz ) ) {
				$to = sprintf( /* translators: %1$s - Datetime; %2$s - Timezone. */
					esc_html_x( '%1$s %2$s', 'Time Time Zone', 'sugar-calendar-lite' ),
					sugar_calendar_format_date_i18n( $tf, $event->start, $event->start_tz ),
					sugar_calendar_format_timezone( $event->start_tz, true )
				);

				// Single time zone.
			} else {
				$to = sugar_calendar_format_timezone( $event->start_tz, true );
			}

			// Wrap in span.
			$stz = '<span class="sc-timezone">' . esc_html( $to ) . '</span>';
		}

		// End time zone.
		if ( ! empty( $event->end_tz ) ) {

			// Maybe show the original date, time, and zone.
			if ( ! empty( $this->timezone ) && ( $this->timezone !== $event->end_tz ) ) {
				$to = sprintf( /* translators: %1$s - Datetime; %2$s - Timezone. */
					esc_html_x( '%1$s %2$s', 'Time Time Zone', 'sugar-calendar-lite' ),
					sugar_calendar_format_date_i18n( $tf, $event->end, $event->end_tz ),
					sugar_calendar_format_timezone( $event->end_tz, true )
				);

				// Single time zone.
			} else {
				$to = sugar_calendar_format_timezone( $event->end_tz, true );
			}

			// Wrap in span.
			$etz = '<span class="sc-timezone">' . esc_html( $to ) . '</span>';

			// Use the start time zone string.
		} elseif ( ! empty( $stz ) ) {
			$etz = $stz;
		}

		// All day, single-day event.
		if ( $event->is_all_day() ) {

			// Start & end.
			$start = sugar_calendar_format_date_i18n( $this->date_format, $event->start, $event->start_tz );
			$end   = sugar_calendar_format_date_i18n( $this->date_format, $event->end, $event->end_tz );

			// Multi-day.
			if ( $event->is_multi( 'j' ) ) {

				// Yearly.
				if ( $event->recurrence === 'yearly' ) {
					$pointer_dates['start_title'] = '<strong>' . esc_html__( 'Start', 'sugar-calendar-lite' ) . '</strong>';
					$pointer_dates['start']       = '<span>' . esc_html( $start ) . '</span>';
					$pointer_dates['end_title']   = '<strong>' . esc_html__( 'End', 'sugar-calendar-lite' ) . '</strong>';
					$pointer_dates['end']         = '<span>' . esc_html( $end ) . '</span>';

					// Monthly.
				} elseif ( $event->recurrence === 'monthly' ) {
					$pointer_dates['start_title'] = '<strong>' . esc_html__( 'Start', 'sugar-calendar-lite' ) . '</strong>';
					$pointer_dates['start']       = '<span>' . esc_html( $start ) . '</span>';
					$pointer_dates['end_title']   = '<strong>' . esc_html__( 'End', 'sugar-calendar-lite' ) . '</strong>';
					$pointer_dates['end']         = '<span>' . esc_html( $end ) . '</span>';

					// No recurrence.
				} else {
					$pointer_dates['start_title'] = '<strong>' . esc_html__( 'Start', 'sugar-calendar-lite' ) . '</strong>';
					$pointer_dates['start']       = '<span>' . esc_html( $start ) . '</span>';
					$pointer_dates['end_title']   = '<strong>' . esc_html__( 'End', 'sugar-calendar-lite' ) . '</strong>';
					$pointer_dates['end']         = '<span>' . esc_html( $end ) . '</span>';
				}

				// Single all-day.
			} else {
				$pointer_dates['all_day_title'] = '<strong>' . esc_html__( 'All Day', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['all_day']       = '<span>' . esc_html( $start ) . '</span>';
			}

			// All other events.
		} elseif ( $event->is_multi( 'j' ) ) {

			// Start & end.
			$start = sugar_calendar_format_date_i18n( $this->date_format, $event->start, $event->start_tz );
			$end   = sugar_calendar_format_date_i18n( $this->date_format, $event->end, $event->end_tz );

			// Yearly.
			if ( $event->recurrence === 'yearly' ) {
				$pointer_dates['start_title'] = '<strong>' . esc_html__( 'Start', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['start']       = '<span>' . esc_html( $start ) . '</span>';
				$pointer_dates['end_title']   = '<strong>' . esc_html__( 'End', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['end']         = '<span>' . esc_html( $end ) . '</span>';

				// Monthly.
			} elseif ( $event->recurrence === 'monthly' ) {
				$pointer_dates['start_title'] = '<strong>' . esc_html__( 'Start', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['start']       = '<span>' . esc_html( $start ) . '</span>';
				$pointer_dates['end_title']   = '<strong>' . esc_html__( 'End', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['end']         = '<span>' . esc_html( $end ) . '</span>';

				// No recurrence.
			} else {

				// Display the start and end time if available.
				if ( ! $event->is_all_day() ) {
					$start .= ' - ' . esc_html( $this->get_event_time( $event->start, $event->start_tz ) );

					// Maybe append time zone.
					if ( ! empty( $stz ) ) {
						$start .= $stz;
					}

					if ( ! empty( $event->end ) ) {
						$end .= ' - ' . esc_html( $this->get_event_time( $event->end, $event->end_tz ) );

						// Maybe append time zone.
						if ( ! empty( $etz ) ) {
							$end .= $etz;
						}
					}
				}

				$pointer_dates['start_title'] = '<strong>' . esc_html__( 'Start', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['start']       = '<span>' . wp_kses( $start, [ 'span' => [ 'class' ] ] ) . '</span>';
				$pointer_dates['end_title']   = '<strong>' . esc_html__( 'End', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['end']         = '<span>' . wp_kses( $end, [ 'span' => [ 'class' ] ] ) . '</span>';
			}

			// Single day.
		} else {

			// Date & Time.
			if ( ! $event->is_empty_date( $event->start ) ) {

				$start = esc_html( $this->get_event_time( $event->start, $event->start_tz ) );

				// Maybe append time zone.
				if ( ! empty( $stz ) ) {
					$start .= $stz;
				}

				$pointer_dates['start_title'] = '<strong>' . esc_html__( 'Start', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['start']       = '<span>' . $start . '</span>';
			}

			// Date & Time.
			if ( ! $event->is_empty_date( $event->end ) && ( $event->start !== $event->end ) ) {
				$end = esc_html( $this->get_event_time( $event->end, $event->end_tz ) );

				// Maybe append time zone.
				if ( ! empty( $etz ) ) {
					$end .= $etz;
				}

				$pointer_dates['end_title'] = '<strong>' . esc_html__( 'End', 'sugar-calendar-lite' ) . '</strong>';
				$pointer_dates['end']       = '<span>' . $end . '</span>';
			}
		}

		/**
		 * Filter the event pointer date.
		 *
		 * @since 3.0.0
		 *
		 * @param array  $pointer_dates Pointer dates.
		 * @param object $event         Event object.
		 */
		$retval = (array) apply_filters( 'sugar_calendar_admin_events_tables_base_get_pointer_dates', $pointer_dates, $event );

		// Separate with line breaks.
		return implode( '', $retval );
	}

	/**
	 * Get event dates for display in a pointer.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return array
	 */
	protected function get_pointer_meta( $event = false ) {

		$pointer_meta = [];

		// Location.
		if ( ! empty( $event->location ) ) {

			// Turn new lines into line breaks.
			$location = preg_replace( '/[\r\n]+/', '<br>', $event->location );

			// Title.
			$pointer_meta['location_title'] = '<strong>' . esc_html__( 'Location', 'sugar-calendar-lite' ) . '</strong>';

			// Location though kses, only allow breaks.
			$pointer_meta['location'] = '<span>' . $location . '</span>';
		}

		/**
		 * Filter the event pointer meta.
		 *
		 * @since 3.0.0
		 *
		 * @param array  $pointer_meta Pointer meta.
		 * @param object $event        Event object.
		 */
		$retval = (array) apply_filters( 'sugar_calendar_admin_events_tables_base_get_pointer_meta', $pointer_meta, $event );

		// Separate with line breaks.
		return implode( '', $retval );
	}

	/**
	 * Return array of allowed HTML tags to use in admin pointers.
	 *
	 * @since 2.0.0
	 *
	 * @return array Allowed HTML tags.
	 */
	public function get_allowed_pointer_tags() {

		return [
			'a'      => [
				'href'                           => [],
				'class'                          => [],
				'data-action'                    => [],
				'data-occurrence-id'             => [],
				'data-sc-notify-event-attendees' => [],
				'data-type'                      => [],
				'target'                         => [],
			],
			'strong' => [],
			'span'   => [ 'class' => [] ],
		];
	}

	/**
	 * Can the current user delete an event?
	 *
	 * @since 2.0.21
	 *
	 * @param object $event Event object.
	 *
	 * @return bool
	 */
	protected function current_user_can_delete( $event = false ) {

		return $this->user_can_delete( get_current_user_id(), $event );
	}

	/**
	 * Can a user ID delete an event?
	 *
	 * This method uses the object_type for the event to determine if the user
	 * can delete the related object_id.
	 *
	 * @since 2.0.21
	 *
	 * @param int    $user_id User ID.
	 * @param object $event   Event object.
	 *
	 * @return bool Default false. True if user can delete event.
	 */
	protected function user_can_delete( $user_id = 0, $event = false ) {

		// Bail if no user was passed.
		if ( empty( $user_id ) ) {
			return false;
		}

		// Get the cap, based on the object_type.
		switch ( $event->object_type ) {
			case 'post':
				$type = get_post_type( $event->object_id );
				$obj  = get_post_type_object( $type );
				$cap  = 'do_not_allow';

				// Map to delete_post if exists.
				if ( ! empty( $obj ) ) {
					$cap = $obj->cap->delete_post;
				}
				break;

			case 'user':
				$cap = 'delete_user';
				break;

			case 'comment':
				$cap = 'delete_comment';
				break;

			default:
				$cap = 'delete_event';
				break;
		}

		// Cast and return.
		return (bool) user_can( $user_id, $cap, $event->object_id );
	}

	/**
	 * Can the current user edit an event?
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return bool
	 */
	public function current_user_can_edit( $event = false ) {

		return $this->user_can_edit( get_current_user_id(), $event );
	}

	/**
	 * Can a user ID edit an event?
	 *
	 * This method uses the object_type for the event to determine if the user
	 * can edit the related object_id.
	 *
	 * @since 2.0.0
	 *
	 * @param int    $user_id User ID.
	 * @param object $event   Event object.
	 *
	 * @return bool Default false. True if user can edit event.
	 */
	protected function user_can_edit( $user_id = 0, $event = false ) {

		// Bail if no user was passed.
		if ( empty( $user_id ) ) {
			return false;
		}

		// Get the cap, based on the object_type.
		switch ( $event->object_type ) {
			case 'post':
				$type = get_post_type( $event->object_id );
				$obj  = get_post_type_object( $type );
				$cap  = 'do_not_allow';

				// Map to edit_post if exists.
				if ( ! empty( $obj ) ) {
					$cap = $obj->cap->edit_post;
				}
				break;

			case 'user':
				$cap = 'edit_user';
				break;

			case 'comment':
				$cap = 'edit_comment';
				break;

			default:
				$cap = 'edit_event';
				break;
		}

		// Cast and return.
		return (bool) user_can( $user_id, $cap, $event->object_id );
	}

	/**
	 * Can the current user view an event?
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 *
	 * @return bool
	 */
	protected function current_user_can_view( $event = false ) {

		return $this->user_can_view( get_current_user_id(), $event );
	}

	/**
	 * Can a user ID view an event?
	 *
	 * This method uses the object_type for the event to determine if the user
	 * can view the related object_id.
	 *
	 * @since 2.0.0
	 *
	 * @param int    $user_id User ID.
	 * @param object $event   Event object.
	 *
	 * @return bool Default false. True if user can view event.
	 */
	protected function user_can_view( $user_id = 0, $event = false ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh, Generic.Metrics.NestingLevel.MaxExceeded

		// Bail if no user was passed.
		if ( empty( $user_id ) ) {
			return false;
		}

		// Get the cap, based on the object_type.
		switch ( $event->object_type ) {
			case 'post':
				$post = get_post( $event->object_id );
				$type = get_post_type( $event->object_id );
				$obj  = get_post_type_object( $type );
				$cap  = 'do_not_allow';

				// Must be viewable by WordPress standards.
				if ( is_post_type_viewable( $obj ) ) {

					// Some statuses require ability to edit.
					if ( in_array( $post->post_status, [ 'pending', 'draft', 'future' ], true ) ) {
						$cap = 'edit_post';

						// Map to view_post if exists.
					} elseif ( ! empty( $obj ) ) {
						$cap = $obj->cap->read_post;
					}
				}
				break;

			case 'user':
				$cap = 'view_user';
				break;

			case 'comment':
				$cap = 'view_comment';
				break;

			default:
				$cap = 'read_event';
				break;
		}

		// Cast and return.
		return (bool) user_can( $user_id, $cap, $event->object_id );
	}

	/** Output & Markup *******************************************************/

	/**
	 * Output default content for columns without explicit handlers.
	 *
	 * @since 2.0.15
	 *
	 * @param object $item        Current item.
	 * @param string $column_name Column name.
	 */
	public function column_default( $item, $column_name ) {

		// Default content.
		$default = '&mdash;';

		/**
		 * Filter column contents.
		 *
		 * @since 3.0.0
		 *
		 * @param string $default Column contents.
		 * @param object $item    Current item.
		 */
		$to_echo = apply_filters( "sugar_calendar_admin_events_tables_base_{$column_name}_contents", $default, $item );

		// Output the content.
		echo $to_echo; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Prepare items for list-table display.
	 *
	 * @since 2.0.0
	 */
	public function prepare_items() {

		// Get query arguments.
		$args = $this->all_query_args();

		// Query for events in the view.
		$this->query = new Event_Query( $args );

		// Set filtered items.
		$this->set_filtered_items();

		// Set all items.
		$this->set_all_items();

		// Set cells.
		$this->set_cells();

		// Set item counts.
		$this->set_item_counts();
	}

	/**
	 * Output an event filter form.
	 *
	 * @since 3.0.0
	 * @since 3.8.2 Added tags parameter preservation for pagination.
	 *
	 * @param string $content Form contents.
	 *
	 * @return void
	 */
	protected function event_filter_form( $content = '' ) {

		// Look for post type screen.
		$post_type = $this->get_primary_post_type();

		// Get taxonomies for this post type.
		$taxonomies = sugar_calendar_get_object_taxonomies(
			$post_type,
			'objects'
		);

		$taxonomies = array_filter( $taxonomies, fn( $taxonomy ) => ! empty( $taxonomy->public ) );
		?>
        <form class="sugar-calendar-item-filters" method="get">
            <input type="hidden" name="object_type" value="<?php echo esc_attr( $this->get_object_type() ); ?>"/>
            <input type="hidden" name="status" value="<?php echo esc_attr( $this->get_status() ); ?>"/>
            <input type="hidden" name="mode" value="<?php echo esc_attr( $this->get_mode() ); ?>"/>
            <input type="hidden" name="page" value="<?php echo esc_attr( $this->get_page() ); ?>"/>
            <input type="hidden" name="cd" value="<?php echo esc_attr( $this->get_day() ); ?>"/>
            <input type="hidden" name="cm" value="<?php echo esc_attr( $this->get_month() ); ?>"/>

			<?php if ( $this->get_mode() === 'list' ) : ?>

                <input type="hidden" name="cy" value="<?php echo esc_attr( $this->get_year() ); ?>"/>
                <input type="hidden" name="cystart" value="<?php echo esc_attr( $this->get_start_year() ); ?>"/>

			<?php else : ?>

                <input type="hidden" name="cy" value="<?php echo esc_attr( $this->get_year() ); ?>"/>

			<?php endif; ?>

            <input type="hidden" name="cz" value="<?php echo esc_attr( $this->get_timezone() ); ?>"/>
            <input type="hidden" name="order" value="<?php echo esc_attr( $this->get_order() ); ?>"/>
            <input type="hidden" name="orderby" value="<?php echo esc_attr( $this->get_orderby() ); ?>"/>
            <input type="hidden" name="max" value="<?php echo esc_attr( $this->get_max() ); ?>"/>
            <input type="hidden" name="s" value="<?php _admin_search_query(); ?>">

			<?php foreach ( $taxonomies as $tax ) : ?>

                <input type="hidden" name="<?php echo esc_attr( $tax->name ); ?>" value="<?php echo esc_attr( $this->get_tax_term( $tax->name ) ); ?>">

			<?php endforeach; ?>

			<?php
			// Preserve tags filter parameter during form submission.
			$tags = $this->get_tags();

			if ( ! empty( $tags ) ) :
			?>
                <input type="hidden" name="sc_event_tags" value="<?php echo esc_attr( implode( ',', $tags ) ); ?>">
			<?php endif; ?>

			<?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
        </form>
		<?php
	}

	/**
	 * Event filter to match the styling of the Media Filter.
	 *
	 * This methods outputs the HTML used to switch modes, search events, filter
	 * with taxonomies.
	 *
	 * @since 2.0.0
	 */
	public function event_filters() {

		// Picker.
		echo $this->mode_picker(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

		ob_start();

		echo $this->extra_tablenav( 'bar' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

		$this->event_filter_form( ob_get_clean() );
	}

	/**
	 * Display attendees notification button.
	 *
	 * @since 3.11.0
	 *
	 * @return void
	 */
	public function event_notifications() {}

	/**
	 * Output the event search form.
	 *
	 * @since 3.0.0
	 *
	 * @return void
	 */
	public function event_search() {

		// Display search reset summary when a search is active.
		$search_term = $this->get_search();

		ob_start();

		if ( ! empty( $search_term ) ) {

			$total_count = $this->get_search_result_count();

			Helper::display_search_reset(
				$total_count,
				strtolower( esc_html__( 'event', 'sugar-calendar-lite' ) ),
				strtolower( esc_html__( 'events', 'sugar-calendar-lite' ) ),
				esc_html__( 'Events', 'sugar-calendar-lite' ),
				remove_query_arg( 's', $this->get_persistent_url() )
			);
		}
		?>

        <p class="search-box">
            <label for="event-search-input" class="screen-reader-text"><?php esc_html_e( 'Search', 'sugar-calendar-lite' ); ?></label>
            <input type="search" id="event-search-input" class="search" placeholder="<?php esc_html_e( 'Search Event...', 'sugar-calendar-lite' ); ?>" name="s" value="<?php _admin_search_query(); ?>">
            <input type="submit" id="search-submit" class="button" value="<?php esc_html_e( 'Search', 'sugar-calendar-lite' ); ?>">
        </p>

		<?php

		$this->event_filter_form( ob_get_clean() );
	}

	/**
	 * Get the search result count to display in the search reset summary.
	 *
	 * Subclasses can override to align the count with what is actually rendered
	 * on screen for the current mode (e.g., list vs. calendar views).
	 *
	 * @since 3.8.2
	 *
	 * @return int
	 */
	protected function get_search_result_count() {

		// If searching and there are no prepared items at all, report 0.
		$has_search  = ( $this->get_search() !== '' );
		$no_filtered = empty( $this->filtered_items );
		$no_all      = empty( $this->all_items );
		$no_query    = ( ! isset( $this->query ) || empty( $this->query->items ) );

		if ( $has_search && $no_filtered && $no_all && $no_query ) {
			return 0;
		}

		if ( ! empty( $this->filtered_items ) && is_array( $this->filtered_items ) ) {

			$ids = array_map( 'absint', wp_list_pluck( $this->filtered_items, 'id' ) );

			return count( array_unique( $ids ) );
		}

		if ( ! empty( $this->all_items ) && is_array( $this->all_items ) ) {

			$ids = array_map( 'absint', wp_list_pluck( $this->all_items, 'id' ) );

			return count( array_unique( $ids ) );
		}

		if ( isset( $this->query ) && ! empty( $this->query->items ) && is_array( $this->query->items ) ) {

			$ids = array_map( 'absint', wp_list_pluck( $this->query->items, 'id' ) );

			return count( array_unique( $ids ) );
		}

		$counts = $this->get_item_counts();

		return isset( $counts['total'] ) ? absint( $counts['total'] ) : 0;
	}

	/**
	 * Output the options menu.
	 *
	 * @since 3.0.0
	 * @since 3.7.0 Title is now mandatory in screen options.
	 *
	 * @return void
	 */
	public function options_menu() {

		global $wp_locale;

		// Get the default preferences.
		$preferences    = get_defaults();
		$start_of_week  = sugar_calendar_get_user_preference( 'start_of_week', $preferences['start_of_week'] );
		$events_max_num = sugar_calendar_get_user_preference( 'events_max_num', $preferences['events_max_num'] );

		?>
		<div class="sugar-calendar-screen-options">
			<button id="sugar-calendar-screen-options-toggle" class="sugar-calendar-screen-options-toggle button" type="button">
			<svg width="17" height="17" viewBox="0 0 17 17" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
				<path d="M16.9922 10H14.8047C14.6224 10.7031 14.349 11.3542 13.9844 11.9531L15.5469 13.4766L13.4375 15.5859L11.875 14.0234C11.3021 14.388 10.6771 14.6615 10 14.8438V16.9922H6.99219V14.8438C6.3151 14.6615 5.67708 14.388 5.07812 14.0234L3.51562 15.5859L1.40625 13.4766L2.96875 11.9141C2.60417 11.3151 2.33073 10.6771 2.14844 10H0V7.03125H2.14844C2.30469 6.35417 2.57812 5.71615 2.96875 5.11719L1.40625 3.55469L3.51562 1.44531L5.03906 3.00781C5.61198 2.64323 6.26302 2.36979 6.99219 2.1875V0H10V2.1875C10.6771 2.34375 11.3021 2.60417 11.875 2.96875L13.4375 1.44531L15.5469 3.55469L14.0234 5.11719C14.388 5.71615 14.6484 6.35417 14.8047 7.03125H16.9922V10ZM8.47656 11.5234C9.3099 11.5234 10.013 11.237 10.5859 10.6641C11.1849 10.0651 11.4844 9.34896 11.4844 8.51562C11.4844 7.68229 11.1849 6.97917 10.5859 6.40625C10.013 5.80729 9.3099 5.50781 8.47656 5.50781C7.64323 5.50781 6.92708 5.80729 6.32812 6.40625C5.75521 6.97917 5.46875 7.68229 5.46875 8.51562C5.46875 9.34896 5.75521 10.0651 6.32812 10.6641C6.92708 11.237 7.64323 11.5234 8.47656 11.5234Z" fill="currentColor"/>
			</svg>
			</button>

            <div class="sugar-calendar-screen-options-menu" style="display: none;">
                <form action="" method="post">
					<?php wp_nonce_field( Area::SLUG ); ?>
                    <input type="hidden" name="mode" value="<?php echo esc_attr( $this->get_mode() ); ?>"/>
                    <input type="hidden" name="cd" value="<?php echo esc_attr( $this->get_day() ); ?>"/>
                    <input type="hidden" name="cm" value="<?php echo esc_attr( $this->get_month() ); ?>"/>
                    <input type="hidden" name="cy" value="<?php echo esc_attr( $this->get_year() ); ?>"/>

                    <fieldset>
                        <legend><?php esc_html_e( 'Columns', 'sugar-calendar-lite' ); ?></legend>
						<?php
						[ $columns, $hidden ] = $this->get_column_info();

						foreach ( $columns as $column_key => $column_display_name ) :

							// Skip if column is cb (checkbox).
							if ( $column_key === 'cb' ) {
								continue;
							}
							?>

                            <label>
								<?php if ( $column_key === 'title' ) : ?>

									<input
										type="checkbox"
										value="<?php echo esc_attr( $column_key ); ?>"
										checked
										disabled
									>
									<input type="hidden" name="sugar-calendar[columns][]" value="<?php echo esc_attr( $column_key ); ?>">

								<?php else : ?>

									<input
										type="checkbox"
										name="sugar-calendar[columns][]"
										value="<?php echo esc_attr( $column_key ); ?>"
										<?php checked( ! in_array( $column_key, $hidden, true ) ); ?>>
								<?php endif; ?>

								<?php
								echo wp_strip_all_tags( $column_display_name ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
								?>
                            </label>

						<?php endforeach; ?>

                    </fieldset>
                    <fieldset>
                        <legend><?php esc_html_e( 'Start of Week', 'sugar-calendar-lite' ); ?></legend>
                        <select name="sugar-calendar[start_of_week]">

							<?php foreach ( range( 0, 6 ) as $week_day ) : ?>

                                <option value="<?php echo absint( $week_day ); ?>" <?php selected( $start_of_week, $week_day ); ?>><?php echo esc_html( $wp_locale->get_weekday( $week_day ) ); ?></option>

							<?php endforeach; ?>

                        </select>
                    </fieldset>
                    <fieldset>
                        <legend><?php esc_html_e( 'Maximum Events', 'sugar-calendar-lite' ); ?></legend>
                        <input type="number" step="1" min="1" max="999" class="code" name="sugar-calendar[events_max_num]" maxlength="3" value="<?php echo absint( $events_max_num ); ?>">
                    </fieldset>
                    <p class="submit">
                        <button type="submit" name="sugar-calendar-submit" class="button"><?php esc_html_e( 'Save Options', 'sugar-calendar-lite' ); ?></button>
                    </p>
                </form>
            </div>
        </div>
		<?php
	}


	/**
	 * Display the table.
	 *
	 * @since 2.0.0
	 */
	public function display() {

		// Start an output buffer.
		ob_start();

		// Top.
		$this->display_tablenav( 'top' );

		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
		?>

		<table class="wp-list-table <?php echo implode( ' ', $this->get_table_classes() ); ?>">
			<thead>
				<tr>
					<?php $this->print_column_headers(); ?>
				</tr>
			</thead>

			<tbody id="the-list" data-wp-lists='list:<?php echo $this->_args['singular']; ?>'>
				<?php $this->display_mode(); ?>
			</tbody>

			<tfoot>
				<tr>
					<?php $this->print_column_headers( false ); ?>
				</tr>
			</tfoot>
		</table>

		<?php
		// Bottom.
		$this->display_tablenav( 'bottom' );

		// End and flush the buffer.
		echo ob_get_clean();

		// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Display a calendar by month and year.
	 *
	 * @since 2.0.0
	 */
	protected function display_mode() {
		// Performed by subclass.
	}

	/**
	 * Generate the table navigation above or below the table.
	 *
	 * @since 2.0.0
	 *
	 * @param string $which Table navigation area.
	 */
	protected function display_tablenav( $which = 'top' ) {

		?>

        <div class="tablenav <?php echo esc_attr( $which ); ?> sugar-calendar-tablenav sugar-calendar-tablenav-<?php echo esc_attr( $which ); ?>">
			<?php

			// Output Month, Year tablenav.
			echo $this->extra_tablenav( $which ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

			// Top only output.
			if ( $which === 'top' ) :

				// Pagination.
				echo $this->extra_tablenav( 'pagination' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

				// Tools.
				echo $this->extra_tablenav( 'tools' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			endif;

			?>
            <br class="clear">
        </div>

		<?php
	}

	/**
	 * Output grid layout rules.
	 *
	 * @since 3.0.0
	 *
	 * @return void
	 */
	public function output_grid_layout() {}

	/**
	 * Method to avoid putting out the default search box.
	 *
	 * @since 2.0.0
	 *
	 * @param string $text     Search box content.
	 * @param string $input_id Search box HTML ID.
	 *
	 * @return string
	 */
	public function search_box( $text = '', $input_id = '' ) {

		return '';
	}

	/**
	 * Message to be displayed when there are no items.
	 *
	 * @since 2.0.0
	 */
	public function no_items() {
		// Do nothing; calendars always have rows.
	}

	/**
	 * Handle bulk action requests.
	 *
	 * @since 2.0.0
	 */
	public function process_bulk_action() {
		// No bulk actions.
	}

	/**
	 * Always have items.
	 *
	 * This method forces WordPress to always show the calendar rows, and never
	 * to trigger the `no_items()` method.
	 *
	 * @since 2.0.0
	 *
	 * @return bool
	 */
	public function has_items() {

		return true;
	}

	/**
	 * Get classes for event in day.
	 *
	 * @since 2.0.0
	 *
	 * @param object $event Event object.
	 * @param int    $cell  Cell index.
	 */
	protected function get_event_classes( $event = 0, $cell = 0 ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		// Empty classes array.
		$classes = [];

		// All day.
		$classes[] = ! empty( $event->all_day )
			? 'all-day'
			: '';

		// Multiple days.
		$start_day = gmdate( 'Y-m-d', strtotime( $event->start ) );
		$end_day   = gmdate( 'Y-m-d', strtotime( $event->end ) );

		if ( $start_day !== $end_day ) {
			$classes[] = 'multiple-days';
		}

		// Recurring.
		$classes[] = ! empty( $event->recurrence )
			? 'recur-' . sanitize_key( $event->recurrence )
			: '';

		// Location.
		$classes[] = get_event_meta( $event->id, 'location', true )
			? 'has-location'
			: '';

		// Color.
		$classes[] = get_event_meta( $event->id, 'color', true )
			? 'has-color'
			: '';

		// Get taxonomies.
		$taxos = sugar_calendar_get_object_taxonomies(
			$this->get_primary_post_type()
		);

		// Maybe loop through taxonomies, and add terms to.
		if ( ! empty( $taxos ) && is_array( $taxos ) ) {

			// Loop through taxonomies.
			foreach ( $taxos as $tax ) {

				// Check term cache first.
				$terms = get_object_term_cache( $event->object_id, $tax );

				// No cache, so query for terms.
				if ( $terms === false ) {
					$terms = wp_get_object_terms( $event->object_id, $tax );
				}

				// Bail if no terms in this taxonomy.
				if ( empty( $terms ) ) {
					continue;
				}

				// Add taxonomy to classes.
				$classes[] = "tax-{$tax}";

				// Loop through terms and add them, too.
				foreach ( $terms as $term ) {
					$classes[] = "term-{$term->slug}";
				}
			}
		}

		// Filter the event classes.
		$classes = array_unique( get_post_class( $classes, $event->object_id ) );

		// Join & return.
		return trim( implode( ' ', $classes ) );
	}

	/**
	 * Is a year/month/day today.
	 *
	 * @since 2.0.0
	 *
	 * @param int $year  Year.
	 * @param int $month Month.
	 * @param int $day   Day.
	 *
	 * @return bool
	 */
	protected function is_today( $year = 0, $month = 0, $day = 0 ) {

		$_year  = (bool) ( $year == gmdate( 'Y', $this->now ) );
		$_month = (bool) ( $month == gmdate( 'n', $this->now ) );
		$_day   = (bool) ( $day == gmdate( 'j', $this->now ) );

		return (bool) ( $_year === true && $_month === true && $_day === true );
	}

	/**
	 * Is a year/month/day a weekend?
	 *
	 * @since 2.2.0
	 *
	 * @param int $year  Year.
	 * @param int $month Month.
	 * @param int $day   Day.
	 *
	 * @return bool
	 */
	protected function is_weekend( $year = 0, $month = 0, $day = 0 ) {

		// Get the day.
		$j = (int) gmdate( 'w', strtotime( "{$year}-{$month}-{$day}" ) );

		// Is Sunday or Saturday.
		$retval = in_array( $j, [ 0, 6 ], true );

		// Return.
		return $retval;
	}

	/**
	 * Is a year/month/day the last of its day-of-week.
	 *
	 * @since 2.2.0
	 *
	 * @param int $year  Year.
	 * @param int $month Month.
	 * @param int $day   Day.
	 *
	 * @return bool
	 */
	protected function is_dow_last( $year = 0, $month = 0, $day = 0 ) {

		// Get number of days in this month.
		$days = (int) gmdate( 't', strtotime( "{$year}-{$month}-{$day}" ) );

		// Is day-of-week.
		$retval = ( $day > ( $days - 7 ) );

		// Return.
		return $retval;
	}

	/**
	 * Get classes for table cell.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	protected function get_cell_classes() {

		// Get the grid positions.
		$cell = $this->get_current_cell( 'index' );

		// Get the start.
		$start = $this->get_current_cell( 'start_dto' );

		$day   = $start->format( 'd' );
		$month = $start->format( 'n' );
		$year  = $start->format( 'Y' );

		// Get day of week, and day key.
		$dow        = ( $cell % $this->day_count );
		$day_key    = strtolower( $start->format( 'l' ) );
		$day_column = "column-{$day_key}";

		// Position & day info.
		$column_number = "column-{$dow}";
		$cell_number   = "cell-{$cell}";
		$day_number    = "day-{$day}";
		$month_number  = "month-{$month}";
		$year_number   = "year-{$year}";

		// Count.
		$count        = 0;
		$count_number = '';
		$has_events   = ! empty( $count )
			? 'not-empty'
			: '';

		// Day specific classes.
		$weekend            = '';
		$is_today           = '';
		$dow_last           = '';
		$dow_ordinal        = '';
		$is_different_month = '';
		$is_past            = ( $this->get_current_cell( 'start' ) < $this->today_first_second ) ? 'past' : '';

		// Day.
		if ( ! empty( $day ) ) {

			// Weekend.
			$weekend = $this->is_weekend( $year, $month, $day )
				? 'weekend'
				: 'weekday';

			// Today.
			$is_today = $this->is_today( $year, $month, $day )
				? 'today'
				: '';

			// Day-of-week last.
			$dow_last = $this->is_dow_last( $year, $month, $day )
				? 'last'
				: '';

			// Day-of-week ordinal.
			$dow_ordinal = 'dow-' . $this->get_dow_ordinal( $day );

			// Different month.
			$is_different_month = $this->month != $month
				? 'different-month'
				: '';
		}

		// Hidden?
		$hidden = in_array( $day_key, $this->get_hidden_columns(), true )
			? 'hidden'
			: '';

		// Assemble classes.
		$classes = array_filter(
			[
				'column',
				$is_today,
				$is_past,
				$hidden,
				$dow_ordinal,
				$dow_last,
				$day_key,
				$weekend,
				$day_column,
				$has_events,
				$count_number,
				$column_number,
				$cell_number,
				$day_number,
				$month_number,
				$year_number,
				$is_different_month,
			]
		);

		// Trim spaces and return.
		return trim( implode( ' ', $classes ) );
	}

	/**
	 * Displays a taxonomy drop-downs for filtering in the bar table navigation.
	 *
	 * @since 3.0.0
	 * @since 3.7.0 Added filter for registered taxonomies.
	 *
	 * @return false|string|void
	 */
	protected function dropdown_taxonomies() {

		// Look for post type screen.
		$post_type = $this->get_primary_post_type();

		// Bail if no post types.
		if ( empty( $post_type ) ) {
			return;
		}

		/**
		 * Filter taxonomies for this post type.
		 *
		 * @since 3.7.0
		 *
		 * @param array $taxonomies Taxonomies.
		 */
		$taxonomies = apply_filters(
			'sugar_calendar_admin_events_tables_base_dropdown_taxonomies',
			sugar_calendar_get_object_taxonomies(
				$post_type,
				'objects'
			)
		);

		// Bail if no taxonomies.
		if ( empty( $taxonomies ) ) {
			return;
		}

		// Start an output buffer.
		ob_start();

		// Loop through taxonomies and setup the dropdowns.
		foreach ( $taxonomies as $tax ) {

			// Skip if private.
			if ( empty( $tax->public ) ) {
				continue;
			}

			$current = $this->get_tax_term( $tax->name );
			?>

            <label class="screen-reader-text" for="<?php echo esc_attr( $tax->name ); ?>">
				<?php
				sprintf( /* translators: %s taxonomy label. */
					esc_html__( 'Filter by %s', 'sugar-calendar-lite' ),
					$tax->labels->name
				);
				?>
            </label>

			<?php
			// Dropdown.
			wp_dropdown_categories(
				[
					'taxonomy'          => $tax->name,
					'name'              => $tax->name,
					'show_option_all'   => $tax->labels->all_items,
					'show_option_none'  => $tax->labels->no_terms,
					'option_none_value' => '__sc_none__',
					'hierarchical'      => $tax->hierarchical,
					'hide_empty'        => false,
					'show_count'        => false,
					'orderby'           => 'name',
					'value_field'       => 'slug',
					'selected'          => $current,
				]
			);
		}

		return ob_get_clean();
	}

	/**
	 * Output month & year inputs, for viewing relevant events.
	 *
	 * @since 2.0.0
	 *
	 * @param string $which Table navigation area.
	 */
	protected function extra_tablenav( $which = '' ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		// Start an output buffer.
		ob_start();

		/**
		 * Fires before extra table navigation.
		 *
		 * @since 3.0.0
		 *
		 * @param Base $table Table instance.
		 */
		do_action( "sugar_calendar_admin_events_tables_base_before_extra_tablenav_{$which}", $this );

		// Bar.
		if ( $which === 'bar' ) :

			// Get the taxonomies.
			$drop = $this->dropdown_taxonomies();

			// Output taxonomies and "Filter" button.
			if ( ! empty( $drop ) ) :
				echo $drop; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				?>

			<?php
			endif;

		// Top.
        elseif ( $which === 'top' ) :

			// Hide the month picker UI in List mode.
			if ( $this->get_mode() !== 'list' ) :
				?>

                <label for="cm" class="screen-reader-text"><?php esc_html_e( 'Switch to this month', 'sugar-calendar-lite' ); ?></label>
                <select name="cm" id="cm">

					<?php for ( $month_index = 1; $month_index <= 12; $month_index++ ) : ?>

                        <option value="<?php echo esc_attr( $month_index ); ?>" <?php selected( $month_index, $this->month ); ?>>
							<?php echo $GLOBALS['wp_locale']->get_month( $month_index ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
                        </option>

					<?php endfor; ?>

                </select>

			<?php
			endif;

			// Show the day input UI for day mode only.
			if ( $this->get_mode() === 'day' ) :
				?>

                <label for="cd" class="screen-reader-text"><?php esc_html_e( 'Set the day', 'sugar-calendar-lite' ); ?></label>
                <input type="number" name="cd" id="cd" value="<?php echo (int) $this->day; ?>" size="2">

			<?php

			// Hide the day input UI for week mode.
            elseif ( $this->get_mode() === 'week' ) :
				?>

                <input type="hidden" name="cd" id="cd" value="<?php echo (int) $this->day; ?>">

			<?php
			endif;

			// Show start & end years for list mode.
			if ( $this->get_mode() === 'list' ) :
				?>
				<div class="sce-admin__table-top-nav__cy">
					<label for="cystart" class="screen-reader-text"><?php esc_html_e( 'Set the first year', 'sugar-calendar-lite' ); ?></label>
					<input type="number" name="cystart" id="cystart" value="<?php echo (int) $this->get_start_year(); ?>">
					<span><?php esc_html_e( 'to', 'sugar-calendar-lite' ); ?></span>
					<label for="cy" class="screen-reader-text"><?php esc_html_e( 'Set the last year', 'sugar-calendar-lite' ); ?></label>
					<input type="number" name="cy" id="cy" value="<?php echo (int) $this->get_year(); ?>">
				</div>
			<?php

			// Show single year for non-list modes.
			else :
				?>

                <label for="cy" class="screen-reader-text"><?php esc_html_e( 'Set the year', 'sugar-calendar-lite' ); ?></label>
                <input type="number" name="cy" id="cy" value="<?php echo (int) $this->year; ?>">

			<?php endif; ?>

            <input type="hidden" name="mode" value="<?php echo esc_attr( $this->get_mode() ); ?>"/>

            <input type="hidden" name="order" value="<?php echo esc_attr( $this->get_order() ); ?>"/>
            <input type="hidden" name="orderby" value="<?php echo esc_attr( $this->get_orderby() ); ?>"/>
            <input type="hidden" name="s" value="<?php echo esc_attr( $this->get_search() ); ?>"/>

			<?php

			// Taxonomies.
			$tax_terms = $this->get_tax_terms();

			if ( ! empty( $tax_terms ) ) :
				foreach ( $tax_terms as $tax => $term ) :
					?>
                    <input type="hidden" name="<?php echo esc_attr( $tax ); ?>" value="<?php echo esc_attr( $term ); ?>"/>
				<?php
				endforeach;
			endif;

			// Output the "View" button.
			submit_button( esc_html_x( 'View', 'verb', 'sugar-calendar-lite' ), 'action', '', false, [ 'id' => 'doaction' ] );

			// Maybe output an "Empty Trash" button.
			if ( ( $this->get_status() === 'trash' ) && current_user_can( get_post_type_object( $this->get_primary_post_type() )->cap->edit_others_posts ) && $this->has_items() ) :
				submit_button( esc_html__( 'Empty Trash', 'sugar-calendar-lite' ), 'apply', 'delete_all_trashed_events', false );
			endif;

			// Nonce for event actions.
			wp_nonce_field( 'event-actions' );

		// Output pagination.
        elseif ( $which === 'pagination' ) :
			echo $this->pagination(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

		// Output tools.
        elseif ( $which === 'tools' ) :
			echo $this->tools(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		endif;

		/**
		 * Fires after extra table navigation.
		 *
		 * @since 3.0.0
		 *
		 * @param Base $table Table instance.
		 */
		do_action( "sugar_calendar_admin_events_tables_base_after_extra_tablenav_{$which}", $this );

		// Return.
		return ob_get_clean();
	}

	/**
	 * Paginate through months & years.
	 *
	 * @since 2.0.0
	 *
	 * @param array $args Pagination arguments.
	 *
	 * @return string
	 */
	protected function pagination( $args = [] ) {

		// Parse args.
		$r = wp_parse_args(
			$args,
			[
				'small'  => '1 month',
				'large'  => '1 year',
				'labels' => [
					'today'      => esc_html__( 'Today', 'sugar-calendar-lite' ),
					'next_small' => esc_html__( 'Next month', 'sugar-calendar-lite' ),
					'next_large' => esc_html__( 'Next year', 'sugar-calendar-lite' ),
					'prev_small' => esc_html__( 'Previous month', 'sugar-calendar-lite' ),
					'prev_large' => esc_html__( 'Previous year', 'sugar-calendar-lite' ),
				],
			]
		);

		// Base URLs.
		$today = $this->get_today_url();

		// Today's small & large timestamps.
		$ts = $this->today;
		$tl = $this->today;

		// Adjust small for month.
		if ( strstr( $r['small'], 'month' ) ) {
			$ts = strtotime( gmdate( 'Y-m-01', $ts ) );
		}

		// Adjust large for month.
		if ( strstr( $r['large'], 'month' ) ) {
			$tl = strtotime( gmdate( 'Y-m-01', $tl ) );
		}

		// Calculate previous & next weeks & months.
		$prev_small = strtotime( "-{$r['small']}", $ts );
		$next_small = strtotime( "+{$r['small']}", $ts );
		$prev_large = strtotime( "-{$r['large']}", $tl );
		$next_large = strtotime( "+{$r['large']}", $tl );

		// Week.
		$prev_small_d = gmdate( 'j', $prev_small );
		$prev_small_m = gmdate( 'n', $prev_small );
		$prev_small_y = gmdate( 'Y', $prev_small );
		$next_small_d = gmdate( 'j', $next_small );
		$next_small_m = gmdate( 'n', $next_small );
		$next_small_y = gmdate( 'Y', $next_small );

		// Month.
		$prev_large_d = gmdate( 'j', $prev_large );
		$prev_large_m = gmdate( 'n', $prev_large );
		$prev_large_y = gmdate( 'Y', $prev_large );
		$next_large_d = gmdate( 'j', $next_large );
		$next_large_m = gmdate( 'n', $next_large );
		$next_large_y = gmdate( 'Y', $next_large );

		// Setup month args.
		$prev_small_args = [
			'cy' => $prev_small_y,
			'cm' => $prev_small_m,
			'cd' => $prev_small_d,
		];
		$prev_large_args = [
			'cy' => $prev_large_y,
			'cm' => $prev_large_m,
			'cd' => $prev_large_d,
		];
		$next_small_args = [
			'cy' => $next_small_y,
			'cm' => $next_small_m,
			'cd' => $next_small_d,
		];
		$next_large_args = [
			'cy' => $next_large_y,
			'cm' => $next_large_m,
			'cd' => $next_large_d,
		];

		// Setup links.
		$prev_small_link = add_query_arg( $prev_small_args, $today );
		$next_small_link = add_query_arg( $next_small_args, $today );
		$prev_large_link = add_query_arg( $prev_large_args, $today );
		$next_large_link = add_query_arg( $next_large_args, $today );

		// Start an output buffer.
		ob_start();
		?>

        <div class="tablenav-pages sugar-calendar-tablenav sugar-calendar-tablenav-pages">

			<?php

			/**
			 * Fires before table pagination.
			 *
			 * @since 3.0.0
			 *
			 * @param Base $table Table instance.
			 */
			do_action( 'sugar_calendar_admin_events_tables_base_before_pagination', $this );
			?>

            <a class="previous-page button" href="<?php echo esc_url( $prev_large_link ); ?>">
                <span class="screen-reader-text"><?php echo esc_html( $r['labels']['prev_large'] ); ?></span>
                <span aria-hidden="true">&laquo;</span>
            </a>

            <a class="previous-page button" href="<?php echo esc_url( $prev_small_link ); ?>">
                <span class="screen-reader-text"><?php echo esc_html( $r['labels']['prev_small'] ); ?></span>
                <span aria-hidden="true">&lsaquo;</span>
            </a>

            <a href="<?php echo esc_url( $today ); ?>" class="previous-page today button">
                <span class="screen-reader-text"><?php echo esc_html( $r['labels']['today'] ); ?></span>
                <span aria-hidden="true"><?php echo esc_html( $r['labels']['today'] ); ?></span>
            </a>

            <a class="next-page button" href="<?php echo esc_url( $next_small_link ); ?>">
                <span class="screen-reader-text"><?php echo esc_html( $r['labels']['next_small'] ); ?></span>
                <span aria-hidden="true">&rsaquo;</span>
            </a>

            <a class="next-page button" href="<?php echo esc_url( $next_large_link ); ?>">
                <span class="screen-reader-text"><?php echo esc_html( $r['labels']['next_large'] ); ?></span>
                <span aria-hidden="true">&raquo;</span>
            </a>

			<?php

			/**
			 * Fires after table pagination.
			 *
			 * @since 3.0.0
			 *
			 * @param Base $table Table instance.
			 */
			do_action( 'sugar_calendar_admin_events_tables_base_after_pagination', $this );
			?>

        </div>

		<?php

		// Return.
		return ob_get_clean();
	}

	/**
	 * Additional tools.
	 *
	 * @since 2.1.1
	 *
	 * @return string
	 */
	private function tools() {

		// Time zone.
		$tztype   = sugar_calendar_get_timezone_type();
		$floating = sugar_calendar_is_timezone_floating();
		$timezone = sugar_calendar_format_timezone( $this->timezone );

		// Start an output buffer.
		ob_start();
		?>

        <div class="tablenav-tools sugar-calendar-tablenav sugar-calendar-tablenav-tools">

			<?php

			/**
			 * Fires before table tools.
			 *
			 * @since 3.0.0
			 *
			 * @param Base $table Table instance.
			 */
			do_action( 'sugar_calendar_admin_events_tables_base_before_tools', $this );

			// Output time zone if not floating or support is enabled.
			if ( ( $floating === false ) || ( $tztype !== 'off' ) ) :
				?>

                <span class="sc-timezone"><?php echo esc_html( $timezone ); ?></span>

			<?php
			endif;

			/**
			 * Fires after table tools.
			 *
			 * @since 3.0.0
			 *
			 * @param Base $table Table instance.
			 */
			do_action( 'sugar_calendar_admin_events_tables_base_after_tools', $this );
			?>

        </div>

		<?php

		// Return.
		return ob_get_clean();
	}

	/**
	 * Display the mode switcher.
	 *
	 * @since 2.0.0
	 *
	 * @param string $which Mode picker area.
	 */
	public function mode_picker( $which = 'top' ) {

		// Only switch on top.
		if ( $which !== 'top' ) {
			return;
		}

		// Get these ahead of the foreach loop.
		$base_url  = $this->get_persistent_url();
		$modes     = $this->get_modes();
		$removable = $this->get_removable_args();
		$mode_url  = remove_query_arg( $removable, $base_url );

		// Start an output buffer.
		ob_start();
		?>

		<div class="sugar-calendar-view-modes">
			<input type="hidden" name="mode" value="<?php echo esc_attr( $this->get_mode() ); ?>"/>

			<?php

			// Loop through modes.
			foreach ( $modes as $mode => $title ) :

				// Setup the URL by adding & removing args.
				$url = add_query_arg(
					[ 'mode' => $mode ],
					$mode_url
				);

				// Setup classes.
				$classes = [
					'sugar-calendar-view-mode',
					'sugar-calendar-view-mode-' . $mode,
				];

				if ( $this->get_mode() === $mode ) {
					$classes[] = 'current';
				}

				$classes = implode( ' ', $classes );
				?>

                <a href="<?php echo esc_url( $url ); ?>"
                   class="<?php echo esc_attr( $classes ); ?>"
                   title="<?php echo esc_attr( $title ); ?>">
                    <span><?php echo esc_html( $title ); ?></span>
                </a>

			<?php endforeach; ?>

        </div>

		<?php

		// Return the output buffer.
		return ob_get_clean();
	}

	/**
	 * Strip timezone formatting from a DateTime format string.
	 *
	 * Used to avoid duplicate time zone output in the specific places where
	 * we manually always output a formatted time zone string.
	 *
	 * @since 2.1.2
	 *
	 * @param string $format Timezone format.
	 *
	 * @return string
	 */
	private function strip_timezone_format( $format = '' ) {

		// Time zone formats to remove.
		$tz_formats = [ 'e', 'I', 'O', 'P', 'T', 'Z' ];

		return str_replace( $tz_formats, '', $format );
	}

	/**
	 * Filters pointer links according to the Event::object_type property.
	 *
	 * This method fires WordPress hooks for third-party plugin support, and
	 * also adds fallback support for unknown object types.
	 *
	 * @since 2.1.8
	 *
	 * @param array  $links Array of pointer links.
	 * @param object $event Event object.
	 *
	 * @return array
	 */
	private function filter_pointer_links( $links = [], $event = false ) {

		// Remove any empty links.
		$links = array_filter( $links );

		/**
		 * Filter pointer links.
		 *
		 * @since 3.0.0
		 *
		 * @param array  $links Array of links.
		 * @param object $event Event object.
		 * @param Base   $table Table instance.
		 */
		$links = (array) apply_filters( 'sugar_calendar_admin_events_tables_base_get_pointer_links', $links, $event, $this );

		// Type of object.
		switch ( $event->object_type ) {
			case 'post':
				$object = get_post( $event->object_id );
				$type   = is_post_type_hierarchical( $object->post_type ) ? 'page' : 'post';
				$filter = "{$type}_row_actions";
				break;

			case 'user':
				$object = get_userdata( $event->object_id );
				$filter = 'user_row_actions';
				break;

			case 'comment':
				$object = get_comment( $event->object_id );
				$filter = 'comment_row_actions';
				break;

			case 'sc_event':
				$object = $event;
				$filter = 'sugar_calendar_admin_event_row_actions';
				break;

			default:
				$object = $event;
				$filter = 'sugar_calendar_admin_default_row_actions';
				break;
		}

		/**
		 * Filter pointer action links.
		 *
		 * @since 3.0.0
		 *
		 * @param array  $links Array of links.
		 * @param object $event Event object.
		 */
		return (array) apply_filters( $filter, $links, $object ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
	}

	/**
	 * Get all items.
	 *
	 * @since 3.6.0
	 *
	 * @return Event[]
	 */
	public function get_all_items() {

		return $this->all_items;
	}

	/**
	 * Get filtered items.
	 *
	 * @since 3.8.2
	 *
	 * @return Event[]
	 */
	public function get_filtered_items() {

		return $this->filtered_items;
	}

	/**
	 * Get the add new button icon DOM.
	 *
	 * @since 3.10.0
	 *
	 * @param bool $include_hour Whether or not to include hour in the button link.
	 * @param bool $is_all_day   Whether or not the button is for an all-day event.
	 *
	 * @return string
	 */
	protected function get_add_new_button( $include_hour = false, $is_all_day = false ) {

		$start = $this->get_current_cell( 'start_dto' );
		$args  = [
			'post_type'      => sugar_calendar_get_event_post_type_id(),
			'sce_start_date' => $start->format( 'Y-m-d' ),
		];

		if ( $include_hour ) {
			$args['sce_start_hour'] = $start->format( 'G' );
		} elseif ( $is_all_day ) {
			$args['sce_all_day'] = 1;
		}

		return sprintf(
			'<div class="sce__add-new-btn"><a href="%1$s" target="_blank"></a></div>',
			esc_url(
				add_query_arg(
					$args,
					get_admin_url( null, 'post-new.php' )
				)
			)
		);
	}
}
