<?php

namespace SmartSlotAppointmentScheduler;

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

if ( ! class_exists( '\SmartSlotAppointmentScheduler\Book_An_Appointment_Report' ) ) {

	/**
	 * Class for generating appointment reports.
	 */
	class Book_An_Appointment_Report extends \WP_List_Table {

		/**
		 * Cache group.
		 *
		 * @var string
		 */
		private const CACHE_GROUP = 'ssas_reports';

		/**
		 * Capability.
		 *
		 * @var string
		 */
		private const CAPABILITY = 'manage_options';

		/**
		 * Report nonce action.
		 *
		 * @var string
		 */
		private const REPORT_NONCE_ACTION = 'booking_report_filters';

		/**
		 * Report nonce name.
		 *
		 * @var string
		 */
		private const REPORT_NONCE_NAME = 'booking_report_nonce';

		/**
		 * Export nonce action.
		 *
		 * @var string
		 */
		private const EXPORT_NONCE_ACTION = 'export_appointments';

		/**
		 * Parent menu slug.
		 *
		 * @var string
		 */
		private $parent_slug;

		/**
		 * Page slug.
		 *
		 * @var string
		 */
		private $slug;

		/**
		 * Page title.
		 *
		 * @var string
		 */
		private $page_title;

		/**
		 * Database table.
		 *
		 * @var string
		 */
		private $table_name;

		/**
		 * Constructor.
		 */
		public function __construct() {

			$this->parent_slug = 'edit.php?post_type=ssas_schedule';
			$this->slug        = 'schedule-reports';
			$this->page_title  = __( 'Booking Reports', 'smartslot-appointment-scheduler' );
			$this->table_name  = 'pkart_ssas_booking_appointment';

			parent::__construct(
				[
					'singular' => 'Appointment',
					'plural'   => 'Appointments',
					'ajax'     => false,
				]
			);

			$this->maybe_export();

			add_action(
				'admin_menu',
				[ $this, 'add_admin_page' ]
			);

			add_filter(
				'set-screen-option',
				[ $this, 'set_screen_option' ],
				10,
				3
			);
		}

		/**
		 * Get columns.
		 *
		 * @return array
		 */
		public function get_columns() {

			return [
				'id'               => __( 'ID', 'smartslot-appointment-scheduler' ),
				'token_number'     => __( 'Token', 'smartslot-appointment-scheduler' ),
				'first_name'       => __( 'Name', 'smartslot-appointment-scheduler' ),
				'email'            => __( 'Email', 'smartslot-appointment-scheduler' ),
				'phone'            => __( 'Phone', 'smartslot-appointment-scheduler' ),
				// 'address'          => __( 'Address', 'smartslot-appointment-scheduler' ), // Disabled for now, as it may contain sensitive information.
				// 'notes'            => __( 'Notes', 'smartslot-appointment-scheduler' ), // Disabled for now, as it may contain sensitive information.
				'categories'       => __( 'Category', 'smartslot-appointment-scheduler' ),
				'services'         => __( 'Services', 'smartslot-appointment-scheduler' ),
				'appointment_date' => __( 'Date', 'smartslot-appointment-scheduler' ),
				'appointment_time' => __( 'Time', 'smartslot-appointment-scheduler' ),
				'status'           => __( 'Status', 'smartslot-appointment-scheduler' ),
			];
		}

		/**
		 * Get sortable columns.
		 *
		 * @return array
		 */
		protected function get_sortable_columns() {

			return [
				'id'               => [ 'id', false ],
				'token_number'     => [ 'token_number', false ],
				'appointment_date' => [ 'appointment_date', false ],
				'appointment_time' => [ 'appointment_time', false ],
				'status'           => [ 'status', false ],
			];
		}

		/**
		 * Get default query arguments.
		 *
		 * @return array
		 */
		private function get_default_query_args() {

			return [
				'search'  => '',
				'status'  => -1,
				'filter'  => '',
				'orderby' => 'id',
				'order'   => 'DESC',
			];
		}

		/**
		 * Get report query arguments.
		 *
		 * @param bool $nonce_already_verified Whether nonce is verified.
		 * @return array
		 */
		private function get_report_query_args(
			$nonce_already_verified = false
		) {

			$args = $this->get_default_query_args();

			if ( ! $nonce_already_verified ) {

				// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Value is immediately verified.
				$nonce = isset( $_GET[ self::REPORT_NONCE_NAME ] )
					? sanitize_text_field(
						wp_unslash(
							$_GET[ self::REPORT_NONCE_NAME ]
						)
					)
					: '';

				if (
					empty( $nonce ) ||
					! wp_verify_nonce(
						$nonce,
						self::REPORT_NONCE_ACTION
					)
				) {
					return $args;
				}
			}

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Protected by report nonce.
			$args['search'] = isset( $_GET['s'] )
				? sanitize_text_field(
					wp_unslash( $_GET['s'] )
				)
				: '';

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Protected by report nonce.
			$args['status'] = isset( $_GET['status'] )
				? absint(
					wp_unslash( $_GET['status'] )
				)
				: -1;

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Protected by report nonce.
			$args['filter'] = isset( $_GET['filter'] )
				? sanitize_key(
					wp_unslash( $_GET['filter'] )
				)
				: '';

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Protected by report nonce.
			$args['orderby'] = isset( $_GET['orderby'] )
				? sanitize_key(
					wp_unslash( $_GET['orderby'] )
				)
				: 'id';

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Protected by report nonce.
			$args['order'] = isset( $_GET['order'] )
				? strtoupper(
					sanitize_key(
						wp_unslash( $_GET['order'] )
					)
				)
				: 'DESC';

			$allowed_columns = array_keys(
				$this->get_sortable_columns()
			);

			if ( ! in_array( $args['orderby'], $allowed_columns, true ) ) {
				$args['orderby'] = 'id';
			}

			if ( ! in_array( $args['order'], [ 'ASC', 'DESC' ], true ) ) {
				$args['order'] = 'DESC';
			}

			if ( ! in_array( $args['filter'], [ '', 'today', 'upcoming' ], true ) ) {
				$args['filter'] = '';
			}

			if (
				-1 !== $args['status'] &&
				! in_array(
					$args['status'],
					[ 0, 1, 2, 3 ],
					true
				)
			) {
				$args['status'] = -1;
			}

			return $args;
		}

		/**
		 * Verify report nonce.
		 *
		 * @return bool
		 */
		private function has_valid_report_nonce() {

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Immediately verified.
			$nonce = isset( $_GET[ self::REPORT_NONCE_NAME ] )
				? sanitize_text_field(
					wp_unslash(
						$_GET[ self::REPORT_NONCE_NAME ]
					)
				)
				: '';

			return ! empty( $nonce ) &&
				wp_verify_nonce(
					$nonce,
					self::REPORT_NONCE_ACTION
				);
		}

		/**
		 * Ensure report nonce exists.
		 *
		 * @return void
		 */
		private function ensure_report_nonce_in_url() {

			if (
				$this->has_valid_report_nonce() ||
				headers_sent()
			) {
				return;
			}

			$redirect_url = add_query_arg(
				[
					'post_type'              => 'ssas_schedule',
					'page'                   => $this->slug,
					self::REPORT_NONCE_NAME => wp_create_nonce(
						self::REPORT_NONCE_ACTION
					),
				],
				admin_url( 'edit.php' )
			);

			wp_safe_redirect( $redirect_url );

			exit;
		}

		/**
		 * Get table data.
		 *
		 * SQL is intentionally passed directly to $wpdb->prepare()
		 * so WordPress Plugin Check can statically verify the query.
		 *
		 * @param int    $per_page Number per page.
		 * @param int    $page_number Page number.
		 * @param string $search Search string.
		 * @param array  $query_args Query arguments.
		 * @return array
		 */
		private function get_table_data(
			$per_page = 10,
			$page_number = 1,
			$search = '',
			$query_args = []
		) {

			global $wpdb;

			$table = $this->table_name;

			$per_page = max(
				1,
				absint( $per_page )
			);

			$page_number = max(
				1,
				absint( $page_number )
			);

			$offset = (
				$page_number - 1
			) * $per_page;

			$query_args = wp_parse_args(
				$query_args,
				$this->get_default_query_args()
			);

			$search = sanitize_text_field( $search );

			$status = (int) $query_args['status'];

			$filter = sanitize_key(
				$query_args['filter']
			);

			$today = current_time( 'Y-m-d' );

			$allowed_columns = array_keys(
				$this->get_sortable_columns()
			);

			$orderby = in_array(
				$query_args['orderby'],
				$allowed_columns,
				true
			)
				? $query_args['orderby']
				: 'id';

			$order = in_array(
				strtoupper( $query_args['order'] ),
				[ 'ASC', 'DESC' ],
				true
			)
				? strtoupper( $query_args['order'] )
				: 'DESC';

			$cache_key = 'table_data_' . md5(
				wp_json_encode(
					[
						'page'      => $page_number,
						'per_page'  => $per_page,
						'search'    => $search,
						'status'    => $status,
						'filter'    => $filter,
						'orderby'   => $orderby,
						'order'     => $order,
						'today'     => $today,
						'cache_ver' => $this->get_cache_version(),
					]
				)
			);

			$cached = wp_cache_get(
				$cache_key,
				self::CACHE_GROUP
			);

			if ( false !== $cached ) {
				return $cached;
			}

			$like = '%' .
				$wpdb->esc_like( $search ) .
				'%';

			/*
			 * Keep the SQL literal directly inside prepare().
			 *
			 * Search is always represented by placeholders. An empty
			 * search produces %% and therefore does not restrict results.
			 *
			 * Status uses -1 as the "all statuses" value.
			 *
			 * Filter uses an empty string for "all dates".
			 */
			if ( 'ASC' === $order ) {

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Required to read appointment data from the plugin's custom database table.
				$results = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT *
						FROM %i
						WHERE (
							token_number LIKE %s
							OR first_name LIKE %s
							OR email LIKE %s
							OR phone LIKE %s
						)
						AND (
							%d = -1
							OR status = %d
						)
						AND (
							%s = ""
							OR (
								%s = "today"
								AND appointment_date = %s
							)
							OR (
								%s = "upcoming"
								AND appointment_date > %s
							)
						)
						ORDER BY %i ASC
						LIMIT %d OFFSET %d',
						$table,
						$like,
						$like,
						$like,
						$like,
						$status,
						$status,
						$filter,
						$filter,
						$today,
						$filter,
						$today,
						$orderby,
						$per_page,
						$offset
					),
					ARRAY_A
				);

			} else {

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Required to read appointment data from the plugin's custom database table.
				$results = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT *
						FROM %i
						WHERE (
							token_number LIKE %s
							OR first_name LIKE %s
							OR email LIKE %s
							OR phone LIKE %s
						)
						AND (
							%d = -1
							OR status = %d
						)
						AND (
							%s = ""
							OR (
								%s = "today"
								AND appointment_date = %s
							)
							OR (
								%s = "upcoming"
								AND appointment_date > %s
							)
						)
						ORDER BY %i DESC
						LIMIT %d OFFSET %d',
						$table,
						$like,
						$like,
						$like,
						$like,
						$status,
						$status,
						$filter,
						$filter,
						$today,
						$filter,
						$today,
						$orderby,
						$per_page,
						$offset
					),
					ARRAY_A
				);
			}

			if ( ! is_array( $results ) ) {
				$results = [];
			}

			wp_cache_set(
				$cache_key,
				$results,
				self::CACHE_GROUP,
				MINUTE_IN_SECONDS
			);

			return $results;
		}

		/**
		 * Get total records.
		 *
		 * SQL is intentionally passed directly to $wpdb->prepare()
		 * so WordPress Plugin Check can statically verify the query.
		 *
		 * @param string $search Search string.
		 * @param array  $query_args Query arguments.
		 * @return int
		 */
		private function get_total_items(
			$search = '',
			$query_args = []
		) {

			global $wpdb;

			$table = $this->table_name;

			$query_args = wp_parse_args(
				$query_args,
				$this->get_default_query_args()
			);

			$search = sanitize_text_field( $search );

			$status = (int) $query_args['status'];

			$filter = sanitize_key(
				$query_args['filter']
			);

			$today = current_time( 'Y-m-d' );

			$cache_key = 'total_items_' . md5(
				wp_json_encode(
					[
						'search'    => $search,
						'status'    => $status,
						'filter'    => $filter,
						'today'     => $today,
						'cache_ver' => $this->get_cache_version(),
					]
				)
			);

			$cached = wp_cache_get(
				$cache_key,
				self::CACHE_GROUP
			);

			if ( false !== $cached ) {
				return (int) $cached;
			}

			$like = '%' .
				$wpdb->esc_like( $search ) .
				'%';

			/*
			 * The complete SQL statement is passed directly to prepare().
			 * There is deliberately no $query, $sql, $where_clause,
			 * or $prepared_query variable.
			 */
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Required to count records in the plugin's custom database table.
			$total_items = $wpdb->get_var(
				$wpdb->prepare(
					'SELECT COUNT(*)
					FROM %i
					WHERE (
						token_number LIKE %s
						OR first_name LIKE %s
						OR email LIKE %s
						OR phone LIKE %s
					)
					AND (
						%d = -1
						OR status = %d
					)
					AND (
						%s = ""
						OR (
							%s = "today"
							AND appointment_date = %s
						)
						OR (
							%s = "upcoming"
							AND appointment_date > %s
						)
					)',
					$table,
					$like,
					$like,
					$like,
					$like,
					$status,
					$status,
					$filter,
					$filter,
					$today,
					$filter,
					$today
				)
			);

			$total_items = max(
				0,
				(int) $total_items
			);

			wp_cache_set(
				$cache_key,
				$total_items,
				self::CACHE_GROUP,
				MINUTE_IN_SECONDS
			);

			return $total_items;
		}

		/**
		 * Get cache version.
		 *
		 * @return string
		 */
		private function get_cache_version() {

			$version = wp_cache_get(
				'reports_cache_version',
				self::CACHE_GROUP
			);

			if ( false === $version ) {

				$version = '1';

				wp_cache_set(
					'reports_cache_version',
					$version,
					self::CACHE_GROUP
				);
			}

			return (string) $version;
		}

		/**
		 * Decode booking taxonomy data.
		 *
		 * @param string $json JSON data.
		 * @return array
		 */
		private function parse_booking_terms( $json ) {

			$data = json_decode(
				(string) $json,
				true
			);

			$result = [
				'categories' => [],
				'services'   => [],
			];

			if ( ! is_array( $data ) ) {
				return $result;
			}

			if (
				isset( $data['categories'] ) ||
				isset( $data['services'] )
			) {

				$result['categories'] = array_values(
					array_filter(
						array_map(
							'absint',
							(array) ( $data['categories'] ?? [] )
						)
					)
				);

				$result['services'] = array_values(
					array_filter(
						array_map(
							'absint',
							(array) ( $data['services'] ?? [] )
						)
					)
				);

				return $result;
			}

			$legacy_ids = array_values(
				array_filter(
					array_map(
						'absint',
						$data
					)
				)
			);

			foreach ( $legacy_ids as $term_id ) {

				$category = get_term(
					$term_id,
					'ssas_categories'
				);

				if (
					! is_wp_error( $category ) &&
					$category
				) {
					$result['categories'][] = $term_id;
				}

				$service = get_term(
					$term_id,
					'ssas_services'
				);

				if (
					! is_wp_error( $service ) &&
					$service
				) {
					$result['services'][] = $term_id;
				}
			}

			return $result;
		}

		/**
		 * Get category names.
		 *
		 * @param string $json Booking taxonomy JSON.
		 * @return string
		 */
		public function get_categories_string( $json ) {

			$data = $this->parse_booking_terms( $json );

			if ( empty( $data['categories'] ) ) {
				return '-';
			}

			$terms = get_terms(
				[
					'taxonomy'   => 'ssas_categories',
					'include'    => $data['categories'],
					'hide_empty' => false,
				]
			);

			if ( is_wp_error( $terms ) || empty( $terms ) ) {
				return '-';
			}

			$names = [];

			foreach ( $terms as $term ) {

				$names[] = html_entity_decode(
					$term->name,
					ENT_QUOTES,
					'UTF-8'
				);
			}

			return implode(
				', ',
				$names
			);
		}

		/**
		 * Get service names.
		 *
		 * @param string $json Booking taxonomy JSON.
		 * @return string
		 */
		public function get_services_string( $json ) {

			$data = $this->parse_booking_terms( $json );

			if ( empty( $data['services'] ) ) {
				return '-';
			}

			$terms = get_terms(
				[
					'taxonomy'   => 'ssas_services',
					'include'    => $data['services'],
					'hide_empty' => false,
				]
			);

			if ( is_wp_error( $terms ) || empty( $terms ) ) {
				return '-';
			}

			$names = [];

			foreach ( $terms as $term ) {

				$names[] = html_entity_decode(
					$term->name,
					ENT_QUOTES,
					'UTF-8'
				);
			}

			return implode(
				', ',
				$names
			);
		}

		/**
		 * Format appointment time.
		 *
		 * @param string $time_value Time.
		 * @return string
		 */
		private function format_appointment_time( $time_value ) {

			$time = \DateTime::createFromFormat(
				'H:i:s',
				$time_value
			);

			if ( ! $time ) {

				$time = \DateTime::createFromFormat(
					'H:i',
					$time_value
				);
			}

			return $time
				? $time->format( 'g:i a' )
				: $time_value;
		}

		/**
		 * Status label.
		 *
		 * @param int $status Status.
		 * @return string
		 */
		private function get_status_label( $status ) {

			$statuses = [
				0 => __( 'Pending', 'smartslot-appointment-scheduler' ),
				1 => __( 'Approved', 'smartslot-appointment-scheduler' ),
				2 => __( 'Cancelled', 'smartslot-appointment-scheduler' ),
				3 => __( 'Completed', 'smartslot-appointment-scheduler' ),
			];

			return $statuses[ $status ] ?? __( 'Unknown', 'smartslot-appointment-scheduler' );
		}

		/**
		 * Prepare items.
		 *
		 * @return void
		 */
		public function prepare_items() {

			if ( ! current_user_can( self::CAPABILITY ) ) {

				wp_die(
					esc_html__(
						'Sorry, you are not allowed to view booking reports.',
						'smartslot-appointment-scheduler'
					)
				);
			}

			$query_args = $this->get_report_query_args();

			$search = $query_args['search'];

			$per_page = $this->get_items_per_page(
				'appointments_per_page',
				10
			);

			$current_page = $this->get_pagenum();

			$data = $this->get_table_data(
				$per_page,
				$current_page,
				$search,
				$query_args
			);

			$total_items = $this->get_total_items(
				$search,
				$query_args
			);

			$this->_column_headers = [
				$this->get_columns(),
				[],
				$this->get_sortable_columns(),
				'id',
			];

			$this->items = $data;

			$this->set_pagination_args(
				[
					'total_items' => $total_items,
					'per_page'    => $per_page,
				]
			);
		}

		/**
		 * Default column output.
		 *
		 * @param array  $item Item.
		 * @param string $column_name Column.
		 * @return string
		 */
		public function column_default(
			$item,
			$column_name
		) {

			switch ( $column_name ) {

				case 'appointment_time':
					return esc_html(
						$this->format_appointment_time(
							$item['appointment_time'] ?? ''
						)
					);

				case 'categories':
					return esc_html(
						$this->get_categories_string(
							$item['category_id'] ?? ''
						)
					);

				case 'services':
					return esc_html(
						$this->get_services_string(
							$item['category_id'] ?? ''
						)
					);

				case 'status':
					return $this->get_status_label(
						(int) ( $item['status'] ?? 0 )
					);

				default:
					return esc_html(
						$item[ $column_name ] ?? ''
					);
			}
		}

		/**
		 * Status column.
		 *
		 * @param array $item Item.
		 * @return string
		 */
		public function column_status( $item ) {

			$current = (int) (
				$item['status'] ?? 0
			);

			$options = [
				0 => __( 'Pending', 'smartslot-appointment-scheduler' ),
				1 => __( 'Approved', 'smartslot-appointment-scheduler' ),
				2 => __( 'Cancelled', 'smartslot-appointment-scheduler' ),
				3 => __( 'Completed', 'smartslot-appointment-scheduler' ),
			];

			$html = sprintf(
				'<select class="appointment-status" data-id="%d">',
				(int) $item['id']
			);

			foreach ( $options as $value => $label ) {

				$disabled = (
					2 === $value
				)
					? ' disabled'
					: '';

				$html .= sprintf(
					'<option value="%d"%s%s>%s</option>',
					(int) $value,
					selected(
						$current,
						$value,
						false
					),
					$disabled,
					esc_html( $label )
				);
			}

			$html .= '</select>';

			return $html;
		}

		/**
		 * Add report page.
		 *
		 * @return void
		 */
		public function add_admin_page() {

			$hook = add_submenu_page(
				$this->parent_slug,
				$this->page_title,
				$this->page_title,
				self::CAPABILITY,
				$this->slug,
				[ $this, 'render_page' ]
			);

			if ( $hook ) {

				add_action(
					"load-$hook",
					[ $this, 'screen_option' ]
				);
			}
		}

		/**
		 * Screen option.
		 *
		 * @return void
		 */
		public function screen_option() {

			add_screen_option(
				'per_page',
				[
					'label'   => __(
						'Number of items per page:',
						'smartslot-appointment-scheduler'
					),
					'default' => 10,
					'option'  => 'appointments_per_page',
				]
			);
		}

		/**
		 * Save screen option.
		 *
		 * @param mixed  $status Status.
		 * @param string $option Option.
		 * @param mixed  $value Value.
		 * @return mixed
		 */
		public function set_screen_option(
			$status,
			$option,
			$value
		) {

			if ( 'appointments_per_page' === $option ) {

				return max(
					1,
					absint( $value )
				);
			}

			return $status;
		}

		/**
		 * Export CSV.
		 *
		 * @param array $query_args Query args.
		 * @return void
		 */
		public function export_csv( $query_args = [] ) {

			$data = $this->get_table_data(
				9999,
				1,
				$query_args['search'] ?? '',
				$query_args
			);

			nocache_headers();

			header(
				'Content-Type: text/csv; charset=utf-8'
			);

			header(
				'Content-Disposition: attachment; filename=appointments.csv'
			);

			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Required for streamed CSV output.
			$output = fopen(
				'php://output',
				'w'
			);

			if ( false === $output ) {

				wp_die(
					esc_html__(
						'Unable to create CSV output.',
						'smartslot-appointment-scheduler'
					)
				);
			}

			$columns = $this->get_columns();

			fputcsv(
				$output,
				array_values( $columns )
			);

			foreach ( $data as $row ) {

				$formatted_row = [];

				foreach ( $columns as $key => $label ) {

					switch ( $key ) {

						case 'categories':
							$formatted_row[] = $this->get_categories_string(
								$row['category_id'] ?? ''
							);
							break;

						case 'services':
							$formatted_row[] = $this->get_services_string(
								$row['category_id'] ?? ''
							);
							break;

						case 'status':
							$formatted_row[] = $this->get_status_label(
								(int) ( $row['status'] ?? 0 )
							);
							break;

						case 'appointment_time':
							$formatted_row[] = $this->format_appointment_time(
								$row['appointment_time'] ?? ''
							);
							break;

						default:
							$formatted_row[] = $row[ $key ] ?? '';
							break;
					}
				}

				fputcsv(
					$output,
					$formatted_row
				);
			}

			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for streamed CSV output.
			fclose( $output );

			exit;
		}

		/**
		 * Export Excel-compatible file.
		 *
		 * @param array $query_args Query args.
		 * @return void
		 */
		public function export_excel( $query_args = [] ) {

			$data = $this->get_table_data(
				9999,
				1,
				$query_args['search'] ?? '',
				$query_args
			);

			nocache_headers();

			header(
				'Content-Type: application/vnd.ms-excel; charset=utf-8'
			);

			header(
				'Content-Disposition: attachment; filename=appointments.xls'
			);

			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Required for streamed Excel output.
			$output = fopen(
				'php://output',
				'w'
			);

			if ( false === $output ) {

				wp_die(
					esc_html__(
						'Unable to create Excel output.',
						'smartslot-appointment-scheduler'
					)
				);
			}

			$columns = $this->get_columns();

			fputcsv(
				$output,
				array_values( $columns ),
				"\t"
			);

			foreach ( $data as $row ) {

				$formatted_row = [];

				foreach ( $columns as $key => $label ) {

					switch ( $key ) {

						case 'categories':
							$formatted_row[] = $this->get_categories_string(
								$row['category_id'] ?? ''
							);
							break;

						case 'services':
							$formatted_row[] = $this->get_services_string(
								$row['category_id'] ?? ''
							);
							break;

						case 'status':
							$formatted_row[] = $this->get_status_label(
								(int) ( $row['status'] ?? 0 )
							);
							break;

						case 'appointment_time':
							$formatted_row[] = $this->format_appointment_time(
								$row['appointment_time'] ?? ''
							);
							break;

						default:
							$formatted_row[] = $row[ $key ] ?? '';
							break;
					}
				}

				fputcsv(
					$output,
					$formatted_row,
					"\t"
				);
			}

			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for streamed Excel output.
			fclose( $output );

			exit;
		}

		/**
		 * Handle exports.
		 *
		 * @return void
		 */
		public function maybe_export() {

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Used only to identify request before nonce verification.
			$page = isset( $_GET['page'] )
				? sanitize_key(
					wp_unslash( $_GET['page'] )
				)
				: '';

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Used only to identify request before nonce verification.
			$action = isset( $_GET['action'] )
				? sanitize_key(
					wp_unslash( $_GET['action'] )
				)
				: '';

			if (
				$this->slug !== $page ||
				! in_array(
					$action,
					[
						'export_csv',
						'export_excel',
					],
					true
				)
			) {
				return;
			}

			if ( ! current_user_can( self::CAPABILITY ) ) {

				wp_die(
					esc_html__(
						'Sorry, you are not allowed to export booking reports.',
						'smartslot-appointment-scheduler'
					)
				);
			}

			check_admin_referer(
				self::EXPORT_NONCE_ACTION
			);

			$query_args = $this->get_report_query_args( true );

			if ( 'export_csv' === $action ) {
				$this->export_csv( $query_args );
			}

			if ( 'export_excel' === $action ) {
				$this->export_excel( $query_args );
			}
		}

		/**
		 * Render page.
		 *
		 * @return void
		 */
		public function render_page() {

			if ( ! current_user_can( self::CAPABILITY ) ) {

				wp_die(
					esc_html__(
						'Sorry, you are not allowed to view booking reports.',
						'smartslot-appointment-scheduler'
					)
				);
			}

			$this->ensure_report_nonce_in_url();

			$this->prepare_items();

			$nonce = wp_create_nonce(
				self::EXPORT_NONCE_ACTION
			);

			$export_url_csv = add_query_arg(
				[
					'post_type' => 'ssas_schedule',
					'page'      => $this->slug,
					'action'    => 'export_csv',
					'_wpnonce'  => $nonce,
				],
				admin_url( 'edit.php' )
			);

			$export_url_excel = add_query_arg(
				[
					'post_type' => 'ssas_schedule',
					'page'      => $this->slug,
					'action'    => 'export_excel',
					'_wpnonce'  => $nonce,
				],
				admin_url( 'edit.php' )
			);

			?>
			<div class="wrap">

				<h1 class="wp-heading-inline">
					<?php echo esc_html( $this->page_title ); ?>
				</h1>

				<div class="ssas-report-header">

					<div class="ssas-left">

						<a
							href="<?php echo esc_url( $export_url_csv ); ?>"
							class="button button-primary"
						>
							<span class="dashicons dashicons-download"></span>

							<?php
							esc_html_e(
								'CSV',
								'smartslot-appointment-scheduler'
							);
							?>
						</a>

						<a
							href="<?php echo esc_url( $export_url_excel ); ?>"
							class="button"
						>
							<span class="dashicons dashicons-media-spreadsheet"></span>

							<?php
							esc_html_e(
								'Excel',
								'smartslot-appointment-scheduler'
							);
							?>
						</a>

					</div>

					<div class="ssas-right">

						<form
							method="get"
							action="<?php echo esc_url( admin_url( 'edit.php' ) ); ?>"
						>

							<input
								type="hidden"
								name="post_type"
								value="ssas_schedule"
							/>

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

							<input
								type="hidden"
								name="<?php echo esc_attr( self::REPORT_NONCE_NAME ); ?>"
								value="<?php echo esc_attr(
									wp_create_nonce(
										self::REPORT_NONCE_ACTION
									)
								); ?>"
							/>

							<?php
							$this->search_box(
								__(
									'Search',
									'smartslot-appointment-scheduler'
								),
								'search_id'
							);
							?>

						</form>

					</div>

				</div>

				<?php $this->display(); ?>

			</div>
			<?php
		}
	}

	/**
	 * Register report class and AJAX handler.
	 */
	add_action(
		'init',
		function () {

			new Book_An_Appointment_Report();

			/**
			 * AJAX appointment status update.
			 */
			add_action(
				'wp_ajax_ssas_update_appointment_status',
				function () {

					if ( ! current_user_can( 'manage_options' ) ) {

						wp_send_json_error(
							[
								'message' => __(
									'Unauthorized.',
									'smartslot-appointment-scheduler'
								),
							],
							403
						);
					}

					check_ajax_referer(
						'ssas_update_status_nonce',
						'nonce'
					);

					global $wpdb;

					$id = isset( $_POST['id'] )
						? absint(
							wp_unslash( $_POST['id'] )
						)
						: 0;

					$status = isset( $_POST['status'] )
						? absint(
							wp_unslash( $_POST['status'] )
						)
						: 0;

					if ( $id <= 0 ) {

						wp_send_json_error(
							[
								'message' => __(
									'Invalid appointment ID.',
									'smartslot-appointment-scheduler'
								),
							],
							400
						);
					}

					if (
						! in_array(
							$status,
							[ 0, 1, 2, 3 ],
							true
						)
					) {

						wp_send_json_error(
							[
								'message' => __(
									'Invalid appointment status.',
									'smartslot-appointment-scheduler'
								),
							],
							400
						);
					}

					$table_name = 'pkart_ssas_booking_appointment';

					$cache_key = 'appointment_status_' . $id;

					$current_status = wp_cache_get(
						$cache_key,
						'ssas_appointment_status'
					);

					if ( false === $current_status ) {

						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Required to read appointment status from the plugin's custom database table.
						$current_status = $wpdb->get_var(
							$wpdb->prepare(
								'SELECT status FROM %i WHERE id = %d',
								$table_name,
								$id
							)
						);

						if ( null !== $current_status ) {

							wp_cache_set(
								$cache_key,
								(int) $current_status,
								'ssas_appointment_status',
								MINUTE_IN_SECONDS
							);
						}
					}

					if ( null === $current_status ) {

						wp_send_json_error(
							[
								'message' => __(
									'Appointment not found.',
									'smartslot-appointment-scheduler'
								),
							],
							404
						);
					}

					if ( 2 === (int) $current_status ) {

						wp_send_json_error(
							[
								'message' => __(
									'Cancelled appointments cannot be modified.',
									'smartslot-appointment-scheduler'
								),
							],
							400
						);
					}

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required update of the plugin's custom appointment table.
					$updated = $wpdb->update(
						$table_name,
						[
							'status' => $status,
						],
						[
							'id' => $id,
						],
						[
							'%d',
						],
						[
							'%d',
						]
					);

					if ( false === $updated ) {

						wp_send_json_error(
							[
								'message' => __(
									'Unable to update appointment status.',
									'smartslot-appointment-scheduler'
								),
							],
							500
						);
					}

					wp_cache_set(
						$cache_key,
						$status,
						'ssas_appointment_status',
						MINUTE_IN_SECONDS
					);

					wp_cache_set(
						'reports_cache_version',
						(string) microtime( true ),
						self::CACHE_GROUP
					);

					wp_send_json_success(
						[
							'message' => __(
								'Appointment status updated.',
								'smartslot-appointment-scheduler'
							),
						]
					);
				}
			);
		},
		11
	);
}
