<?php

namespace SugarCalendarBookings\Calendar;

use DateInterval;
use DatePeriod;
use DateTime;
use DateTimeImmutable;
use SugarCalendarBookings\Appointments\AppointmentModel;
use SugarCalendarBookings\Calendar\Admin\BaseCalendarSlot;
use SugarCalendarBookings\Options;
use SugarCalendarBookings\Plugin;
use SugarCalendarBookings\Support\Helpers\Date;
use SugarCalendarBookings\Support\Helpers\Time;

/**
 * Class CalendarHelpers.
 *
 * @since 1.0.0
 */
class CalendarHelpers {

	/**
	 * Floor rounding.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	const ROUNDING_FLOOR = 'floor';

	/**
	 * Ceil rounding.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	const ROUNDING_CEIL = 'ceil';

	/**
	 * Get week days in slug form, starting from
	 * start of the week plugin setting.
	 *
	 * @since 1.0.0
	 *
	 * @return string[]
	 */
	public static function get_week_days() {

		$days = [
			'monday',
			'tuesday',
			'wednesday',
			'thursday',
			'friday',
			'saturday',
			'sunday',
		];

		$starting_day = Plugin::get( Options::class )->get( 'display.start_of_week' ) - 1;

		return array_merge(
			array_slice( $days, $starting_day ),
			array_slice( $days, 0, $starting_day )
		);
	}

	/**
	 * Get time period of a day's hours or use time bounds if provided.
	 *
	 * @since 1.0.0
	 * @since 1.4.0 Renamed from `get_hours` to `get_hours_for_day`.
	 *
	 * @param DateTimeImmutable                $date_time Current datetime.
	 * @param array{start: int, end: int}|null $bounds    Display bounds (minutes from midnight).
	 *
	 * @return DatePeriod
	 */
	public static function get_hours_for_day( $date_time = null, $bounds = null ) {

		if ( is_null( $date_time ) ) {
			$date_time = Date::create()->setTimezone( Date::get_default_timezone() );
		}

		if ( $bounds === null ) {
			$start = $date_time->setTime( 0, 0 );
			$end   = $date_time->setTime( 23, 59, 59 );
		} else {
			$start = self::minutes_to_time( $date_time, $bounds['start'], 0 );
			$end   = self::minutes_to_time( $date_time, $bounds['end'], 59 );
		}

		return new DatePeriod(
			$start,
			DateInterval::createFromDateString( '60 minutes' ),
			$end
		);
	}

	/**
	 * Get an hour's grid column CSS rule.
	 *
	 * @since 1.0.0
	 *
	 * @param DateTimeImmutable $datetime     Current datetime.
	 * @param DateTimeImmutable $min_datetime Optional minimum datetime.
	 * @param DateTimeImmutable $max_datetime Optional maximum datetime.
	 *
	 * @return string
	 */
	public static function get_hour_grid_template_row_track( $datetime, $min_datetime = null, $max_datetime = null ) {

		if (
			$min_datetime !== null &&
			$datetime <= $min_datetime
		) {
			return 1;
		} elseif (
			$max_datetime !== null &&
			$datetime >= $max_datetime
		) {
			return -1;
		}

		return $datetime->format( 'a-g-i' );
	}

	/**
	 * Convert minutes from midnight to a DateTimeImmutable on the given date.
	 *
	 * @since 1.4.0
	 *
	 * @param DateTimeImmutable $date    Base date.
	 * @param int               $minutes Minutes from midnight.
	 * @param int               $seconds Seconds component.
	 *
	 * @return DateTimeImmutable
	 */
	private static function minutes_to_time( DateTimeImmutable $date, int $minutes, int $seconds = 0 ): DateTimeImmutable {

		return $date->setTime( (int) floor( $minutes / 60 ), $minutes % 60, $seconds );
	}

	/**
	 * Get grid template rows for weekly hours.
	 *
	 * @since 1.0.0
	 * @since 1.4.0 Added $bounds and $visible_bounds parameters.
	 *
	 * @param DateTimeImmutable                $date_time      Current datetime.
	 * @param array{start: int, end: int}|null $bounds         Full bounds to generate tracks for (minutes from midnight).
	 * @param array{start: int, end: int}|null $visible_bounds Visible bounds; rows outside this range get 0 height.
	 *
	 * @return array
	 */
	public static function get_hour_grid_template_rows( $date_time = null, $bounds = null, $visible_bounds = null ) {

		$rows = [];

		foreach ( static::get_hours_for_day( $date_time, $bounds ) as $hour ) {
			if ( $visible_bounds !== null ) {
				$minutes = Time::minutes_from_midnight( $hour );

				if ( $minutes < $visible_bounds['start'] || $minutes > $visible_bounds['end'] ) {
					continue;
				}
			}

			$rows[] = '[' . static::get_hour_grid_template_row_track( $hour ) . '] 36px';
		}

		return $rows;
	}

	/**
	 * Get hour track names that fall outside the visible bounds.
	 *
	 * Used to generate display: none rules for orphaned hour cells
	 * when AJAX updates CSS but not the HTML body.
	 *
	 * @since 1.4.0
	 *
	 * @param DateTimeImmutable                $date_time      Current datetime.
	 * @param array{start: int, end: int}|null $full_bounds    Full bounds (all days).
	 * @param array{start: int, end: int}|null $visible_bounds Visible bounds (visible days only).
	 *
	 * @return string[] Track names outside visible range (e.g., ['pm-8-00', 'pm-9-00']).
	 */
	public static function get_hidden_hour_tracks( $date_time, $full_bounds, $visible_bounds ) {

		if ( $full_bounds === null || $visible_bounds === null ) {
			return [];
		}

		$hidden = [];

		foreach ( static::get_hours_for_day( $date_time, $full_bounds ) as $hour ) {
			$minutes = Time::minutes_from_midnight( $hour );

			if ( $minutes < $visible_bounds['start'] || $minutes > $visible_bounds['end'] ) {
				$hidden[] = static::get_hour_grid_template_row_track( $hour );
			}
		}

		return $hidden;
	}

	/**
	 * Round datetime to the nearest interval in the display timezone.
	 *
	 * Rounding must align to local-time boundaries so the result maps to
	 * the calendar grid, which is generated in the display timezone. Rounding
	 * the raw Unix timestamp instead aligns to UTC boundaries and breaks
	 * placement for timezones with non-whole-hour offsets (e.g. Asia/Kolkata).
	 *
	 * @since 1.0.0
	 * @since 1.4.0 Round against the display timezone offset.
	 *
	 * @param DateTimeImmutable $date_time     Current datetime.
	 * @param int               $interval      Rounding interval, in minutes.
	 * @param string            $rounding_mode Rounding mode.
	 *
	 * @return DateTimeImmutable
	 */
	public static function round_datetime( $date_time, $interval, $rounding_mode = self::ROUNDING_FLOOR ) {

		$local_date_time = $date_time->setTimezone( Date::get_default_timezone() );
		$offset          = $local_date_time->getOffset();
		$step            = $interval * 60;
		$local_seconds   = ( $local_date_time->getTimestamp() + $offset ) / $step;

		switch ( $rounding_mode ) {
			case static::ROUNDING_FLOOR:
				$local_seconds = floor( $local_seconds );
				break;

			case static::ROUNDING_CEIL:
				$local_seconds = ceil( $local_seconds );
				break;
		}

		$utc_timestamp = (int) ( $local_seconds * $step ) - $offset;

		return $local_date_time->setTimestamp( $utc_timestamp );
	}

	/**
	 * Sort appointments, first by starting hour, then by duration.
	 *
	 * @since 1.0.0
	 * @since 1.4.0 Added cluster end row calculation.
	 *
	 * @param array<AppointmentModel> $appointments Appointments.
	 * @param BaseCalendarSlot        $slot         Calendar slot.
	 *
	 * @return array
	 */
	public static function get_appointment_clusters( $appointments, $slot ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.NestingLevel.MaxExceeded

		// Build nodes.
		$nodes = array_map(
			function ( $appointment ) use ( $slot ) {

				// Convert appointment dates to local timezone for processing.
				$appointment_start = $appointment->start_at->setTimezone( Date::get_default_timezone() );
				$appointment_end   = $appointment->end_at->setTimezone( Date::get_default_timezone() );

				// Starting datetime, snapped to one-hour increments.
				$start_row = max(
					static::round_datetime( $appointment_start, 60 ),
					$slot->get_start()
				);

				// Ending datetime, snapped to one-hour increments.
				$end_row = static::calculate_cluster_end_row( $appointment_end, $slot );

				// Starting datetime, snapped to 5-minutes increments.
				$start_subrow = max(
					static::round_datetime( $appointment_start, 5 ),
					$start_row
				);

				// Ending datetime, snapped to 5-minutes increments.
				$end_subrow = min(
					static::round_datetime( $appointment_end, 5, static::ROUNDING_CEIL ),
					$end_row
				);

				$start_minutes = ( $start_subrow->getTimestamp() - $start_row->getTimestamp() ) / 60;
				$end_minutes   = ( $end_row->getTimestamp() - $end_subrow->getTimestamp() ) / 60;
				$duration      = floor( $end_subrow->getTimestamp() - $start_subrow->getTimestamp() ) / 60;
				$start_offset  = round( $start_minutes / 60, 2 );
				$end_offset    = round( $end_minutes / 60, 2 );
				$grid_slot     = new BaseCalendarSlot( $start_row, $end_row );
				$subgrid_slot  = new BaseCalendarSlot( $start_subrow, $end_subrow );

				return [
					'appointment'  => $appointment,
					'grid_slot'    => $grid_slot,
					'subgrid_slot' => $subgrid_slot,
					'duration'     => $duration,
					'start_offset' => $start_offset,
					'end_offset'   => $end_offset,
					'column'       => 1,
					'nesting'      => 0,
				];
			},
			$appointments
		);

		// Filter out nodes with invalid duration.
		$nodes = array_filter( $nodes, fn( $node ) => $node['duration'] > 0 );

		// Sort by start date, then duration.
		usort(
			$nodes,
			function ( $node, $other_node ) {

				$slot       = $node['subgrid_slot'];
				$other_slot = $other_node['subgrid_slot'];

				if ( $slot->get_start() != $other_slot->get_start() ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
					return $slot->get_start() < $other_slot->get_start() ? -1 : 1;
				}

				return $slot->get_duration() >= $other_slot->get_duration() ? -1 : 1;
			}
		);

		// Group in clusters.
		// Nodes in a cluster overlap each other.
		$clusters     = [];
		$cluster_slot = null;

		foreach ( $nodes as $node ) {
			$subgrid_slot = $node['subgrid_slot'];

			if (
				$cluster_slot === null ||
				! $subgrid_slot->overlaps( $cluster_slot, true )
			) {
				$cluster_slot = new BaseCalendarSlot(
					$subgrid_slot->get_start(),
					$subgrid_slot->get_end()
				);

				$clusters[] = [
					'nodes' => [ $node ],
					'slot'  => $cluster_slot,
				];
			} else {
				// Grow the slot that represents this cluster's time span.
				$cluster_slot->set_start( min( $subgrid_slot->get_start(), $cluster_slot->get_start() ) );
				$cluster_slot->set_end( max( $subgrid_slot->get_end(), $cluster_slot->get_end() ) );

				$clusters[ count( $clusters ) - 1 ]['nodes'][] = $node;
			}
		}

		// phpcs:disable Squiz.Commenting.InlineComment.SpacingBefore
		// Calculate columns and vertical offsets.
		//
		// ┌─────1┬─────2┐
		// │      │      │
		// └──────┴──────┘
		// Nodes starting on the same grid row have increasing column.
		//
		// ┌─────1┐
		// 1 ┌─────1┐
		// └─2      │
		//   └──────┘
		// Nodes that start on different grid rows,
		// but overlap with one of the cluster's nodes,
		// share that node's column, but have a incremental nesting.
		// phpcs:enable Squiz.Commenting.InlineComment.SpacingBefore
		foreach ( $clusters as $c => $cluster ) {
			$previous_node = null;

			// Number of columns for this cluster.
			$columns = 1;

			foreach ( $cluster['nodes'] as $n => $node ) {
				if ( $previous_node !== null ) {
					$grid_slot             = $node['grid_slot'];
					$subgrid_slot          = $node['subgrid_slot'];
					$previous_grid_slot    = $previous_node['grid_slot'];
					$previous_subgrid_slot = $previous_node['subgrid_slot'];
					$previous_nesting      = $previous_node['nesting'];

					// Start on the previous node column.
					$node['column'] = $previous_node['column'];

					// Reset the nesting.
					$node['nesting'] = 0;

					if ( $grid_slot->get_start() == $previous_grid_slot->get_start() ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
						// If start date-times match, move right by one column.
						++$node['column'];

						// Also increment the number of columns of this cluster.
						++$columns;
					} elseif ( $subgrid_slot->overlaps( $previous_subgrid_slot ) ) {
						// If start date-times don't match, check if they overlap.
						// They overlap - use the same column but increase nesting.
						$node['nesting'] = $previous_nesting + 1;
					} else {
						// They don't overlap - find the first available column (starting from 1).
						// Walk backwards to find any overlapping node and use its column.
						$found_overlap = false;
						$i             = $n - 1;

						while ( $i >= 0 && ! $found_overlap ) {
							$check_node = $cluster['nodes'][ $i ];

							if ( $subgrid_slot->overlaps( $check_node['subgrid_slot'] ) ) {
								$node['column']  = $check_node['column'];
								$node['nesting'] = $check_node['nesting'] + 1;
								$found_overlap   = true;
							}
							--$i;
						}

						// If no overlap found, use column 1.
						if ( ! $found_overlap ) {
							$node['column']  = 1;
							$node['nesting'] = 0;
					}
					}

					$cluster['nodes'][ $n ] = $node;
				}

				$previous_node = $node;
			}

			// Normalize columns: find minimum column and shift all columns so minimum is 1.
			$min_column = 1;

			foreach ( $cluster['nodes'] as $node ) {
				if ( $node['column'] < $min_column ) {
					$min_column = $node['column'];
				}
			}

			// Shift all columns to start from 1.
			if ( $min_column < 1 ) {
				$shift = 1 - $min_column;

				foreach ( $cluster['nodes'] as $n => $node ) {
					$cluster['nodes'][ $n ]['column'] = $node['column'] + $shift;
				}
			}

			// Calculate max column to ensure all nodes are visible.
			$max_column = 1;

			foreach ( $cluster['nodes'] as $node ) {
				if ( $node['column'] > $max_column ) {
					$max_column = $node['column'];
				}
			}
			$columns = max( $columns, $max_column );

			// Round cluster slot to grid increments.
			$cluster_slot = $cluster['slot'];

			$cluster_slot->set_start( static::round_datetime( $cluster_slot->get_start(), 60 ) );
			$cluster_slot->set_end( static::round_datetime( $cluster_slot->get_end(), 60, static::ROUNDING_CEIL ) );

			$cluster['slot']    = $cluster_slot;
			$cluster['columns'] = $columns;

			$clusters[ $c ] = $cluster;
		}

		return $clusters;
	}

	/**
	 * Calculate the end row for appointment clustering.
	 *
	 * For appointments that cross midnight, extends to end of day (23:59:59)
	 * instead of clamping to 23:00 to ensure they have visible duration.
	 *
	 * @since 1.4.0
	 *
	 * @param DateTimeImmutable $appointment_end Appointment end time in display timezone.
	 * @param BaseCalendarSlot  $slot            The calendar slot being processed.
	 *
	 * @return DateTimeImmutable The calculated end row datetime.
	 */
	private static function calculate_cluster_end_row( $appointment_end, $slot ) {

		$appointment_end_rounded = static::round_datetime( $appointment_end, 60, static::ROUNDING_CEIL );
		$slot_end_date           = $slot->get_end()->format( 'Y-m-d' );
		$appointment_end_date    = $appointment_end_rounded->format( 'Y-m-d' );

		// If appointment ends on a different day than the slot, use end of slot day (23:59:59).
		if ( $appointment_end_date !== $slot_end_date ) {
			return $slot->get_end()->setTime( 23, 59, 59 );
		}

		return min( $appointment_end_rounded, $slot->get_end()->setTime( 23, 0, 0 ) );
	}

	/**
	 * Return a list of available days as defined in Availability settings.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public static function get_available_days() {

		$working_schedule = Plugin::get( Options::class )->get( 'availability.working_schedule' );
		$working_schedule = array_filter(
			$working_schedule['regular'],
			fn( $schedule ) => $schedule['type'] === 'weekday' && ! empty( $schedule['periods'] )
		);

		return array_map(
			fn( $schedule ) => strtolower( Date::create( 'Sunday' )->modify( '+ ' . $schedule['weekday'] . ' days' )->format( 'l' ) ),
			$working_schedule
		);
	}

	/**
	 * Return a list of data attributes targeting
	 * available hour slots as defined in Availability settings.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public static function get_available_hours() {

		$working_schedule = Plugin::get( Options::class )->get( 'availability.working_schedule' );

		return static::build_hour_selectors_from_schedule( $working_schedule['regular'] );
	}

	/**
	 * Build hour cell CSS selectors from a regular working schedule.
	 *
	 * Converts weekday schedule rules into CSS attribute selectors
	 * that target hour cells in the calendar grid.
	 *
	 * @since 1.4.0
	 *
	 * @param array $regular Regular working schedule rules.
	 *
	 * @return array Array of CSS selectors like `[data-row=am-9-00][data-column=monday]`.
	 */
	public static function build_hour_selectors_from_schedule( array $regular ) {

		$weekday_rules = array_filter(
			$regular,
			fn( $schedule ) => $schedule['type'] === 'weekday'
		);

		$selectors = [];

		foreach ( $weekday_rules as $schedule ) {
			$date    = Date::create( 'Sunday' )->modify( '+ ' . $schedule['weekday'] . ' days' );
			$periods = $schedule['periods'] ?? [];

			foreach ( $periods as $period ) {
				$datetimes = new DatePeriod(
					$date->modify( '+ ' . $period['start'] . ' minutes' ),
					DateInterval::createFromDateString( '60 minutes' ),
					$date->modify( '+ ' . $period['end'] . ' minutes' )
				);

				foreach ( $datetimes as $datetime ) {
					$row         = static::get_hour_grid_template_row_track( $datetime );
					$column      = strtolower( $datetime->format( 'l' ) );
					$selectors[] = "[data-row=$row][data-column=$column]";
				}
			}
		}

		return $selectors;
	}

	/**
	 * Output add appointment button.
	 *
	 * @since 1.0.0
	 *
	 * @param DateTimeImmutable $start_datetime Start datetime.
	 * @param DateTimeImmutable $end_datetime   End datetime.
	 *
	 * @return void
	 */
	public static function add_appointment_button( $start_datetime, $end_datetime ) {

		?>
		<button class="sugar-calendar-bookings-calendar__add-appointment-btn"
			title="<?php echo esc_attr__( 'Add appointment', 'sugar-calendar-bookings-scheduling-appointments-lite' ); ?>"
			data-start-at="<?php echo esc_attr( Date::format_display_date( $start_datetime, DateTime::ATOM ) ); ?>"
			data-end-at="<?php echo esc_attr( Date::format_display_date( $end_datetime, DateTime::ATOM ) ); ?>"></button>
		<?php
	}
}
