<?php
/**
 * Unified TutorHelper availability-calendar data service.
 *
 * @package TutorHelper_Bookings
 */

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

/**
 * Combines availability occurrences and whole-date exclusions.
 *
 * This class returns structured calendar data. It does not render
 * HTML and does not save availability or exclusion records.
 */
final class TutorHelper_Availability_Calendar {

	/**
	 * General time-based availability.
	 *
	 * @var string
	 */
	const TYPE_GENERAL_AVAILABILITY =
		'general_availability';

	/**
	 * Date outside normal term time.
	 *
	 * @var string
	 */
	const TYPE_OUTSIDE_TERM_TIME =
		'outside_term_time';

	/**
	 * Tutor leave or other full-day unavailability.
	 *
	 * @var string
	 */
	const TYPE_TUTOR_LEAVE =
		'tutor_leave';

	/**
	 * Public holiday.
	 *
	 * @var string
	 */
	const TYPE_PUBLIC_HOLIDAY =
		'public_holiday';

    

    	/**
	 * Return calendar classification definitions.
	 *
	 * Colours are semantic identifiers for the interface. The data
	 * service does not output CSS or rely on colour alone.
	 *
	 * @return array
	 */
	public static function get_display_types() {
		return array(
			self::TYPE_GENERAL_AVAILABILITY => array(
				'label' => __(
					'Working day',
					'tutorhelper-bookings'
				),
				'colour_key' => 'green',
				'icon'       => 'check',
			),

			self::TYPE_OUTSIDE_TERM_TIME => array(
				'label' => __(
					'Outside term time',
					'tutorhelper-bookings'
				),
				'colour_key' => 'yellow',
				'icon'       => 'school',
			),

			self::TYPE_TUTOR_LEAVE => array(
				'label' => __(
					'Tutor on leave',
					'tutorhelper-bookings'
				),
				'colour_key' => 'grey',
				'icon'       => 'block',
			),

			self::TYPE_PUBLIC_HOLIDAY => array(
				'label' => __(
					'Public holiday',
					'tutorhelper-bookings'
				),
				'colour_key' => 'orange',
				'icon'       => 'event',
			),
		);
	}

    	/**
	 * Validate and parse a calendar date.
	 *
	 * @param mixed $date Raw date.
	 *
	 * @return DateTimeImmutable|WP_Error
	 */
	private static function parse_date(
		$date
	) {
		$date = sanitize_text_field(
			(string) $date
		);

		if ( empty( $date ) ) {
			return new WP_Error(
				'tutorhelper_calendar_date_missing',
				__(
					'A calendar date is missing.',
					'tutorhelper-bookings'
				)
			);
		}

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

		$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(
				'tutorhelper_calendar_date_invalid',
				__(
					'The calendar date is invalid.',
					'tutorhelper-bookings'
				)
			);
		}

		return $parsed;
	}


    	/**
	 * Validate a requested calendar range.
	 *
	 * @param string $range_start Start date.
	 * @param string $range_end   End date.
	 *
	 * @return array|WP_Error Parsed start and end dates.
	 */
	private static function validate_range(
		$range_start,
		$range_end
	) {
		$start = self::parse_date(
			$range_start
		);

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

		$end = self::parse_date(
			$range_end
		);

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

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

		return array(
			'start' => $start,
			'end'   => $end,
		);
	}


    	/**
	 * Return the upcoming twelve-month calendar range.
	 *
	 * The range begins on the first day of the supplied month and
	 * ends on the final day of the eleventh following month.
	 *
	 * A start date may be supplied for deterministic tests.
	 *
	 * @param string $reference_date Optional Y-m-d date.
	 *
	 * @return array|WP_Error
	 */
	public static function get_twelve_month_range(
		$reference_date = ''
	) {
		if ( empty( $reference_date ) ) {
			$reference_date = current_time(
				'Y-m-d'
			);
		}

		$reference = self::parse_date(
			$reference_date
		);

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

		$start = $reference->modify(
			'first day of this month'
		);

		$end = $start
			->add(
				new DateInterval( 'P12M' )
			)
			->sub(
				new DateInterval( 'P1D' )
			);

		return array(
			'start_date' => $start->format(
				'Y-m-d'
			),
			'end_date' => $end->format(
				'Y-m-d'
			),
		);
	}


    	/**
	 * Build the default data structure for one calendar date.
	 *
	 * @param DateTimeImmutable $date  Calendar date.
	 * @param string            $today Current local date.
	 *
	 * @return array
	 */
	private static function create_empty_day(
		$date,
		$today
	) {
		$date_string = $date->format(
			'Y-m-d'
		);

		return array(
			'date' =>
				$date_string,

			'weekday' =>
				(int) $date->format( 'N' ),

			'is_today' =>
				$date_string === $today,

			'availability' =>
				array(),

			'exclusions' =>
				array(),

			'display_types' =>
				array(),

			'has_availability' =>
				false,

			'has_blocking_exclusion' =>
				false,

			'is_student_bookable' =>
				false,
		);
	}


    	/**
	 * Create an indexed date collection.
	 *
	 * @param DateTimeImmutable $start Start date.
	 * @param DateTimeImmutable $end   End date.
	 *
	 * @return array Dates indexed by Y-m-d.
	 */
	private static function create_date_index(
		$start,
		$end
	) {
		$dates = array();

		$current = $start;

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

		while ( $current <= $end ) {
			$date_string = $current->format(
				'Y-m-d'
			);

			$dates[ $date_string ] =
				self::create_empty_day(
					$current,
					$today
				);

			$current = $current->add(
				new DateInterval( 'P1D' )
			);
		}

		return $dates;
	}


    	/**
	 * Convert an availability occurrence into calendar-safe data.
	 *
	 * @param array $occurrence Availability occurrence.
	 *
	 * @return array
	 */
	private static function normalise_availability(
		$occurrence
	) {
		return array(
			'availability_id' => isset(
				$occurrence['availability_id']
			)
				? absint(
					$occurrence[
						'availability_id'
					]
				)
				: 0,

			'title' => isset(
				$occurrence['title']
			)
				? sanitize_text_field(
					$occurrence['title']
				)
				: '',

			'start_time' => isset(
				$occurrence['start_time']
			)
				? sanitize_text_field(
					$occurrence['start_time']
				)
				: '',

			'end_time' => isset(
				$occurrence['end_time']
			)
				? sanitize_text_field(
					$occurrence['end_time']
				)
				: '',

			'timezone' => isset(
				$occurrence['timezone']
			)
				? sanitize_text_field(
					$occurrence['timezone']
				)
				: '',

			'location_types' => isset(
				$occurrence['location_types']
			)
				&& is_array(
					$occurrence['location_types']
				)
					? array_values(
						array_map(
							'sanitize_key',
							$occurrence[
								'location_types'
							]
						)
					)
					: array(),

			'allowed_booking_type_ids' => isset(
				$occurrence[
					'allowed_booking_type_ids'
				]
			)
				&& is_array(
					$occurrence[
						'allowed_booking_type_ids'
					]
				)
					? array_values(
						array_map(
							'absint',
							$occurrence[
								'allowed_booking_type_ids'
							]
						)
					)
					: array(),
		);
	}


    	/**
	 * Add availability occurrences to indexed calendar dates.
	 *
	 * @param array $dates       Date index.
	 * @param array $occurrences Availability occurrences.
	 *
	 * @return array
	 */
	private static function apply_availability(
		$dates,
		$occurrences
	) {
		foreach ( $occurrences as $occurrence ) {
			if (
				! is_array( $occurrence )
				|| empty( $occurrence['date'] )
			) {
				continue;
			}

			$date = sanitize_text_field(
				$occurrence['date']
			);

			if ( ! isset( $dates[ $date ] ) ) {
				continue;
			}

			$dates[ $date ]['availability'][] =
				self::normalise_availability(
					$occurrence
				);

			$dates[ $date ]['has_availability'] =
				true;

			self::add_display_type(
				$dates[ $date ],
				self::TYPE_GENERAL_AVAILABILITY
			);
		}

		return $dates;
	}


    	/**
	 * Add a display type without creating duplicates.
	 *
	 * @param array  $day  Calendar day, passed by reference.
	 * @param string $type Display type.
	 *
	 * @return void
	 */
	private static function add_display_type(
		&$day,
		$type
	) {
		if (
			! in_array(
				$type,
				$day['display_types'],
				true
			)
		) {
			$day['display_types'][] =
				$type;
		}
	}


    	/**
	 * Map a stored exclusion type to a calendar display type.
	 *
	 * @param string $exclusion_type Stored type.
	 *
	 * @return string
	 */
	private static function map_exclusion_type(
		$exclusion_type
	) {
		switch ( sanitize_key( $exclusion_type ) ) {
			case TutorHelper_Date_Exclusions::
				TYPE_OUTSIDE_TERM_TIME:
				return self::TYPE_OUTSIDE_TERM_TIME;

			case TutorHelper_Date_Exclusions::
				TYPE_HOLIDAY:
				return self::TYPE_PUBLIC_HOLIDAY;

			case TutorHelper_Date_Exclusions::
				TYPE_UNAVAILABLE:
				return self::TYPE_TUTOR_LEAVE;

			default:
				return '';
		}
	}


    	/**
	 * Convert an exclusion into calendar-safe data.
	 *
	 * @param object $exclusion Exclusion record.
	 *
	 * @return array
	 */
	private static function normalise_exclusion(
		$exclusion
	) {
		return array(
			'exclusion_id' => absint(
				$exclusion->id
			),

			'title' => sanitize_text_field(
				$exclusion->title
			),

			'exclusion_type' => sanitize_key(
				$exclusion->exclusion_type
			),

			'display_type' =>
				self::map_exclusion_type(
					$exclusion->exclusion_type
				),

			'start_date' => sanitize_text_field(
				$exclusion->start_date
			),

			'end_date' => sanitize_text_field(
				$exclusion->end_date
			),
		);
	}


    	/**
	 * Add exclusion ranges to indexed calendar dates.
	 *
	 * @param array $dates      Date index.
	 * @param array $exclusions Exclusion records.
	 *
	 * @return array
	 */
	private static function apply_exclusions(
		$dates,
		$exclusions
	) {
		foreach ( $exclusions as $exclusion ) {
			if (
				! is_object( $exclusion )
				|| empty( $exclusion->start_date )
				|| empty( $exclusion->end_date )
			) {
				continue;
			}

			$display_type =
				self::map_exclusion_type(
					$exclusion->exclusion_type
				);

			if ( empty( $display_type ) ) {
				continue;
			}

			$start = self::parse_date(
				$exclusion->start_date
			);

			$end = self::parse_date(
				$exclusion->end_date
			);

			if (
				is_wp_error( $start )
				|| is_wp_error( $end )
			) {
				continue;
			}

			$current = $start;

			while ( $current <= $end ) {
				$date = $current->format(
					'Y-m-d'
				);

				if ( isset( $dates[ $date ] ) ) {
					$dates[ $date ]['exclusions'][] =
						self::normalise_exclusion(
							$exclusion
						);

					/*
					* Outside term time is informational when the
					* tutor has explicitly created availability.
					*
					* Tutor availability therefore overrides the
					* normal term-date restriction.
					*/
					if (
						self::TYPE_OUTSIDE_TERM_TIME
						!== $display_type
					) {
						$dates[ $date ][
							'has_blocking_exclusion'
						] = true;
					}

					self::add_display_type(
						$dates[ $date ],
						$display_type
					);
				}

				$current = $current->add(
					new DateInterval( 'P1D' )
				);
			}
		}

		return $dates;
	}


    	/**
	 * Calculate final day-level booking flags.
	 *
	 * @param array $dates Date index.
	 *
	 * @return array
	 */
	private static function finalise_days(
		$dates
	) {
		foreach ( $dates as $date => $day ) {
			$dates[ $date ]['is_student_bookable'] =
				! empty(
					$day['has_availability']
				)
				&& empty(
					$day[
						'has_blocking_exclusion'
					]
				);
		}

		return $dates;
	}


    	/**
	 * Build unified calendar data for a tutor and date range.
	 *
	 * @param int    $tutor_user_id Tutor user ID.
	 * @param string $range_start   Start date in Y-m-d format.
	 * @param string $range_end     End date in Y-m-d format.
	 *
	 * @return array|WP_Error
	 */
	public static function get_range(
		$tutor_user_id,
		$range_start,
		$range_end
	) {
		$tutor_user_id = absint(
			$tutor_user_id
		);

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

		$tutor = get_user_by(
			'id',
			$tutor_user_id
		);

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

		$range = self::validate_range(
			$range_start,
			$range_end
		);

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

		$occurrences =
			TutorHelper_Booking_Availability::
				get_occurrences(
					$tutor_user_id,
					$range_start,
					$range_end
				);

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

		$exclusions =
			TutorHelper_Date_Exclusions::get_all(
				array(
					'tutor_user_id' =>
						$tutor_user_id,
					'active_only' =>
						true,
					'range_start' =>
						$range_start,
					'range_end' =>
						$range_end,
				)
			);

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

		$dates = self::create_date_index(
			$range['start'],
			$range['end']
		);

		$dates = self::apply_availability(
			$dates,
			$occurrences
		);

		$dates = self::apply_exclusions(
			$dates,
			$exclusions
		);

		$dates = self::finalise_days(
			$dates
		);

		return array(
			'tutor_user_id' =>
				$tutor_user_id,

			'range_start' =>
				$range_start,

			'range_end' =>
				$range_end,

			'day_count' =>
				count( $dates ),

			'days' =>
				array_values( $dates ),

			'display_types' =>
				self::get_display_types(),
		);
	}

    /**
	 * Build the upcoming twelve-month calendar.
	 *
	 * @param int    $tutor_user_id Tutor user ID.
	 * @param string $reference_date Optional date for tests.
	 *
	 * @return array|WP_Error
	 */
	public static function get_upcoming_twelve_months(
		$tutor_user_id,
		$reference_date = ''
	) {
		$range = self::get_twelve_month_range(
			$reference_date
		);

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

		return self::get_range(
			$tutor_user_id,
			$range['start_date'],
			$range['end_date']
		);
	}
}
