<?php
/**
 * Calendar-ready booking data service.
 *
 * @package TutorHelper_Bookings
 */

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

/**
 * Retrieves booking records prepared for local calendar display.
 */
final class TutorHelper_Booking_Calendar_Data {

	/**
	 * Fallback Booking Type colour.
	 *
	 * @var string
	 */
	const FALLBACK_COLOUR = '#328BB8';

	/**
	 * Maximum number of records returned for one visible period.
	 *
	 * This is intentionally generous for a calendar page while still
	 * protecting the administration request from an unbounded query.
	 *
	 * @var int
	 */
	const MAX_RESULTS = 2000;

	/**
	 * Return a valid display timezone.
	 *
	 * @param mixed $timezone Raw timezone value.
	 *
	 * @return DateTimeZone|WP_Error
	 */
	private static function normalise_timezone(
		$timezone
	) {
		$timezone = sanitize_text_field(
			(string) $timezone
		);

		if ( '' === $timezone ) {
			return wp_timezone();
		}

		try {
			return new DateTimeZone(
				$timezone
			);
		} catch ( Exception $exception ) {
			return new WP_Error(
				'tutorhelper_calendar_timezone_invalid',
				__(
					'The calendar timezone is invalid.',
					'tutorhelper-bookings'
				)
			);
		}
	}

	/**
	 * Parse an exact local calendar date.
	 *
	 * @param mixed        $date     Raw Y-m-d date.
	 * @param DateTimeZone $timezone Local timezone.
	 * @param string       $code     Error code.
	 * @param string       $message  Error message.
	 *
	 * @return DateTimeImmutable|WP_Error
	 */
	private static function parse_local_date(
		$date,
		$timezone,
		$code,
		$message
	) {
		$date = sanitize_text_field(
			(string) $date
		);

		$parsed =
			DateTimeImmutable::createFromFormat(
				'!Y-m-d',
				$date,
				$timezone
			);

		$errors =
			DateTimeImmutable::getLastErrors();

		if (
			false === $parsed
			|| (
				is_array( $errors )
				&& (
					0 < $errors['warning_count']
					|| 0 < $errors['error_count']
				)
			)
			|| $parsed->format( 'Y-m-d' )
				!== $date
		) {
			return new WP_Error(
				$code,
				$message
			);
		}

		return $parsed;
	}

	/**
	 * Build UTC boundaries for an inclusive local date range.
	 *
	 * The returned end is exclusive.
	 *
	 * @param mixed        $date_from Inclusive local start date.
	 * @param mixed        $date_to   Inclusive local end date.
	 * @param DateTimeZone $timezone  Display timezone.
	 *
	 * @return array|WP_Error
	 */
	private static function build_utc_range(
		$date_from,
		$date_to,
		$timezone
	) {
		$local_start =
			self::parse_local_date(
				$date_from,
				$timezone,
				'tutorhelper_calendar_start_date_invalid',
				__(
					'The calendar start date is invalid.',
					'tutorhelper-bookings'
				)
			);

		if ( is_wp_error( $local_start ) ) {
			return $local_start;
		}

		$local_end =
			self::parse_local_date(
				$date_to,
				$timezone,
				'tutorhelper_calendar_end_date_invalid',
				__(
					'The calendar end date is invalid.',
					'tutorhelper-bookings'
				)
			);

		if ( is_wp_error( $local_end ) ) {
			return $local_end;
		}

		if ( $local_end < $local_start ) {
			return new WP_Error(
				'tutorhelper_calendar_date_range_invalid',
				__(
					'The calendar end date must not be before its start date.',
					'tutorhelper-bookings'
				)
			);
		}

		/*
		 * Add one calendar day in the local timezone. This remains correct
		 * when the period crosses a daylight-saving boundary.
		 */
		$local_end_exclusive =
			$local_end->modify( '+1 day' );

		$utc_timezone =
			new DateTimeZone( 'UTC' );

		return array(
			'local_start' =>
				$local_start,

			'local_end' =>
				$local_end,

			'local_end_exclusive' =>
				$local_end_exclusive,

			'utc_start' =>
				$local_start->setTimezone(
					$utc_timezone
				),

			'utc_end_exclusive' =>
				$local_end_exclusive->setTimezone(
					$utc_timezone
				),
		);
	}

	/**
	 * Return a safe Booking Type colour.
	 *
	 * @param mixed $colour Raw colour.
	 *
	 * @return string
	 */
	private static function normalise_colour(
		$colour
	) {
		$colour = sanitize_hex_color(
			(string) $colour
		);

		return $colour
			? $colour
			: self::FALLBACK_COLOUR;
	}

	/**
	 * Parse one stored UTC database datetime.
	 *
	 * @param mixed        $value    UTC database datetime.
	 * @param DateTimeZone $timezone Display timezone.
	 *
	 * @return DateTimeImmutable|null
	 */
	private static function parse_utc_datetime(
		$value,
		$timezone
	) {
		$value = sanitize_text_field(
			(string) $value
		);

		if ( '' === $value ) {
			return null;
		}

		$utc =
			DateTimeImmutable::createFromFormat(
				'!Y-m-d H:i:s',
				$value,
				new DateTimeZone( 'UTC' )
			);

		$errors =
			DateTimeImmutable::getLastErrors();

		if (
			false === $utc
			|| (
				is_array( $errors )
				&& (
					0 < $errors['warning_count']
					|| 0 < $errors['error_count']
				)
			)
		) {
			return null;
		}

		return $utc->setTimezone(
			$timezone
		);
	}

	/**
	 * Return allowed statuses for a normal calendar view.
	 *
	 * @return array
	 */
	private static function get_visible_statuses() {
		$statuses = array(
			TutorHelper_Booking_Records::
				STATUS_CONFIRMED,

			TutorHelper_Booking_Records::
				STATUS_UNCONFIRMED,

			TutorHelper_Booking_Records::
				STATUS_CANCELLED,
		);

		/**
		 * Filters booking statuses shown in the normal calendar.
		 *
		 * Cancelled records remain visible for booking history but do not
 		 * block availability.
		 *
		 * @param array $statuses Visible booking statuses.
		 */
		$statuses = apply_filters(
			'tutorhelper_calendar_booking_statuses',
			$statuses
		);

		$clean_statuses = array();

		foreach (
			is_array( $statuses )
				? $statuses
				: array()
			as $status
		) {
			$status = sanitize_key(
				$status
			);

			if (
				'' !== $status
				&& ! in_array(
					$status,
					$clean_statuses,
					true
				)
			) {
				$clean_statuses[] =
					$status;
			}
		}

		return $clean_statuses;
	}

	/**
	 * Query bookings overlapping a UTC range.
	 *
	 * @param int               $tutor_user_id    Tutor WordPress ID.
	 * @param DateTimeImmutable $utc_start        Inclusive UTC boundary.
	 * @param DateTimeImmutable $utc_end_exclusive Exclusive UTC boundary.
	 * @param array             $statuses         Visible statuses.
	 *
	 * @return array
	 */
	private static function query_records(
		$tutor_user_id,
		$utc_start,
		$utc_end_exclusive,
		$statuses
	) {
		global $wpdb;

		$bookings_table =
			TutorHelper_Booking_Records::
				get_table_name();

		$types_table =
			TutorHelper_Booking_Types::
				get_table_name();

		if (
			'' === $bookings_table
			|| '' === $types_table
			|| empty( $statuses )
		) {
			return array();
		}

		$status_placeholders = implode(
			', ',
			array_fill(
				0,
				count( $statuses ),
				'%s'
			)
		);

		$values = array(
			$bookings_table,
			$types_table,
			absint( $tutor_user_id ),
			$utc_end_exclusive->format(
				'Y-m-d H:i:s'
			),
			$utc_start->format(
				'Y-m-d H:i:s'
			),
		);

		foreach ( $statuses as $status ) {
			$values[] = $status;
		}

		$values[] = absint(
			self::MAX_RESULTS
		);

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- IN() placeholders are generated solely from sanitized %s status tokens; all values are passed to $wpdb->prepare().
		$results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Calendar booking data must reflect current booking state; stale cached results could display obsolete availability.
			$wpdb->prepare(
				"SELECT
					b.id,
					b.tutor_user_id,
					b.student_user_id,
					b.student_name,
					b.student_email,
					b.booking_type_id,
					b.booking_start,
					b.booking_end,
					b.timezone,
					b.location_type,
					b.location_details,
					b.status,
					b.source,
					b.notes,
					b.cancelled_by,
					b.cancellation_reason,
					bt.title AS booking_type_title,
					bt.colour AS booking_type_colour
				FROM %i b
				LEFT JOIN %i bt
					ON bt.id = b.booking_type_id
				WHERE b.tutor_user_id = %d
					AND b.booking_start < %s
					AND b.booking_end > %s
					AND b.status IN ({$status_placeholders})
				ORDER BY
					b.booking_start ASC,
					b.id ASC
				LIMIT %d",
				...$values
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

		return is_array( $results )
			? $results
			: array();
	}

	/**
	 * Convert one database row into calendar-ready data.
	 *
	 * @param object       $row      Database row.
	 * @param DateTimeZone $timezone Display timezone.
	 *
	 * @return array|null
	 */
	private static function prepare_record(
		$row,
		$timezone
	) {
		if ( ! is_object( $row ) ) {
			return null;
		}

		$local_start =
			self::parse_utc_datetime(
				$row->booking_start,
				$timezone
			);

		$local_end =
			self::parse_utc_datetime(
				$row->booking_end,
				$timezone
			);

		if (
			! $local_start
			|| ! $local_end
			|| $local_end <= $local_start
		) {
			return null;
		}

		$duration_minutes = (int) (
			(
				$local_end->getTimestamp()
				- $local_start->getTimestamp()
			)
			/ MINUTE_IN_SECONDS
		);

		$type_title =
			sanitize_text_field(
				(string) $row
					->booking_type_title
			);

		if ( '' === $type_title ) {
			$type_title = __(
				'Booking Type unavailable',
				'tutorhelper-bookings'
			);
		}

		return array(
			'id' =>
				absint( $row->id ),

			'booking_type_id' =>
				absint(
					$row->booking_type_id
				),

			'booking_type_title' =>
				$type_title,

			'booking_type_colour' =>
				self::normalise_colour(
					$row->booking_type_colour
				),

			'student_user_id' =>
				absint(
					$row->student_user_id
				),

			'student_name' =>
				sanitize_text_field(
					$row->student_name
				),

			'student_email' =>
				sanitize_email(
					$row->student_email
				),

			'tutor_user_id' =>
				absint(
					$row->tutor_user_id
				),

			'status' =>
				sanitize_key(
					$row->status
				),

			'source' =>
				sanitize_key(
					$row->source
				),

			'location_type' =>
				sanitize_key(
					$row->location_type
				),

			'location_details' =>
				sanitize_textarea_field(
					$row->location_details
				),

			'notes' =>
				sanitize_textarea_field(
					$row->notes
				),

			'cancelled_by' =>
				sanitize_key(
					isset( $row->cancelled_by )
						? $row->cancelled_by
						: ''
				),

			'cancellation_reason' =>
				sanitize_textarea_field(
					isset( $row->cancellation_reason )
						? $row->cancellation_reason
						: ''
				),

			'price_minor_units' =>
				absint(
					$row->price_minor_units
				),

			'currency' =>
				strtoupper(
					sanitize_text_field(
						$row->currency
					)
				),

			'pricing_basis' =>
				sanitize_key(
					$row->pricing_basis
				),

			'pricing_exception' =>
				1 === absint(
					$row->pricing_exception
				),

			'payment_method' =>
				sanitize_key(
					$row->payment_method
				),

			'payment_status' =>
				sanitize_key(
					$row->payment_status
				),

			'duration_minutes' =>
				max(
					0,
					$duration_minutes
				),

			'local_date' =>
				$local_start->format(
					'Y-m-d'
				),

			'local_end_date' =>
				$local_end->format(
					'Y-m-d'
				),

			'local_start_time' =>
				$local_start->format(
					'H:i'
				),

			'local_end_time' =>
				$local_end->format(
					'H:i'
				),

			'local_start' =>
				$local_start,

			'local_end' =>
				$local_end,

			'utc_start' =>
				sanitize_text_field(
					$row->booking_start
				),

			'utc_end' =>
				sanitize_text_field(
					$row->booking_end
				),

			'spans_multiple_dates' =>
				$local_start->format(
					'Y-m-d'
				)
				!== $local_end->format(
					'Y-m-d'
				),
		);
	}

	/**
	 * Retrieve calendar-ready bookings for an inclusive local date range.
	 *
	 * @param array $args {
	 *     Calendar query arguments.
	 *
	 *     @type int    $tutor_user_id Tutor WordPress user ID.
	 *     @type string $date_from     Inclusive local Y-m-d date.
	 *     @type string $date_to       Inclusive local Y-m-d date.
	 *     @type string $timezone      Optional display timezone.
	 * }
	 *
	 * @return array|WP_Error
	 */
	public static function get_for_range(
		$args
	) {
		$args = wp_parse_args(
			is_array( $args )
				? $args
				: array(),
			array(
				'tutor_user_id' =>
					0,

				'date_from' =>
					'',

				'date_to' =>
					'',

				'timezone' =>
					'',
			)
		);

		$tutor_user_id = absint(
			$args['tutor_user_id']
		);

		if ( ! $tutor_user_id ) {
			return new WP_Error(
				'tutorhelper_calendar_tutor_missing',
				__(
					'A tutor is required for the calendar.',
					'tutorhelper-bookings'
				)
			);
		}

		if (
			! get_user_by(
				'id',
				$tutor_user_id
			)
		) {
			return new WP_Error(
				'tutorhelper_calendar_tutor_not_found',
				__(
					'The calendar tutor account could not be found.',
					'tutorhelper-bookings'
				)
			);
		}

		$timezone =
			self::normalise_timezone(
				$args['timezone']
			);

		if ( is_wp_error( $timezone ) ) {
			return $timezone;
		}

		$range =
			self::build_utc_range(
				$args['date_from'],
				$args['date_to'],
				$timezone
			);

		if ( is_wp_error( $range ) ) {
			return $range;
		}

		$rows =
			self::query_records(
				$tutor_user_id,
				$range['utc_start'],
				$range[
					'utc_end_exclusive'
				],
				self::get_visible_statuses()
			);

		$records = array();

		foreach ( $rows as $row ) {
			$record =
				self::prepare_record(
					$row,
					$timezone
				);

			if ( null !== $record ) {
				$records[] =
					$record;
			}
		}

		return array(
			'tutor_user_id' =>
				$tutor_user_id,

			'timezone' =>
				$timezone->getName(),

			'date_from' =>
				$range['local_start']
					->format( 'Y-m-d' ),

			'date_to' =>
				$range['local_end']
					->format( 'Y-m-d' ),

			'utc_from' =>
				$range['utc_start']
					->format(
						'Y-m-d H:i:s'
					),

			'utc_to_exclusive' =>
				$range[
					'utc_end_exclusive'
				]->format(
					'Y-m-d H:i:s'
				),

			'bookings' =>
				$records,

			'total' =>
				count( $records ),
		);
	}

	/**
	 * Group calendar records by their local start date.
	 *
	 * This helper will be used by the weekly and monthly renderers.
	 *
	 * @param array $records Prepared booking records.
	 *
	 * @return array
	 */
	public static function group_by_local_date(
		$records
	) {
		$grouped = array();

		foreach (
			is_array( $records )
				? $records
				: array()
			as $record
		) {
			if (
				! is_array( $record )
				|| empty(
					$record['local_date']
				)
			) {
				continue;
			}

			$date = sanitize_text_field(
				$record['local_date']
			);

			if (
				! isset(
					$grouped[ $date ]
				)
			) {
				$grouped[ $date ] =
					array();
			}

			$grouped[ $date ][] =
				$record;
		}

		ksort( $grouped );

		return $grouped;
	}
}
