<?php

declare( strict_types=1 );

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

/**
 * Handles slot generation and availability checking.
 * Locking is no longer done via a separate table — pending bookings act as the lock.
 */
class Playard_Slot {

	/**
	 * Returns available time slots for a given field on a given date.
	 * Checks facility open_days first — returns [] immediately for closed days.
	 *
	 * @param int    $field_id
	 * @param string $date  Format: Y-m-d
	 * @return array<int,array<string,mixed>>
	 */
	public function get_day_slots( int $field_id, string $date ): array {
		return $this->get_slots_for_date( $field_id, $date );
	}

	/**
	 * Full slot engine: generates all slots for a field on a date with status overlay.
	 * Returns [] for closed days, holidays, or dates outside the booking window.
	 *
	 * Each slot: { start: "HH:MM", end: "HH:MM", status: "available|booked|blocked", price: float }
	 *
	 * @param int    $field_id
	 * @param string $date     Y-m-d
	 * @return array<int,array<string,mixed>>
	 */
	public function get_slots_for_date( int $field_id, string $date, bool $admin_mode = false ): array {
		$facility = playard_get_facility();
		$field    = playard_get_field( $field_id );

		if ( ! $facility || ! $field ) {
			return [];
		}

		// Use noon to avoid DST boundary issues; gmdate() for PHPCS compliance — day-of-week is timezone-safe here.
		$day_key   = strtolower( (string) gmdate( 'D', (int) strtotime( $date . ' 12:00:00' ) ) );
		$open_days = json_decode( (string) ( $facility->open_days ?? '[]' ), true );
		if ( ! is_array( $open_days ) || ! in_array( $day_key, $open_days, true ) ) {
			return apply_filters( 'playard_closed_day_slots', [], $field_id, $date );
		}

		// Check 2: holiday
		if ( playard_is_holiday( $date ) ) {
			return apply_filters( 'playard_holiday_slots', [], $field_id, $date );
		}

		// Check 3: advance booking window — skipped for admin walk-in bookings.
		if ( ! $admin_mode && ! $this->is_bookable_date( $date, (array) $field ) ) {
			return [];
		}

		$raw_slots = $this->generate_raw_slots(
			(string) $facility->open_time,
			(string) $facility->close_time,
			(int) $field->slot_duration,
			(int) $field->buffer_time
		);

		return $this->apply_statuses( $raw_slots, $field_id, $date, $field, (string) ( $facility->timezone ?: 'UTC' ) );
	}

	/**
	 * Generates raw { start, end } slot pairs from facility open/close times.
	 * Uses pure arithmetic (seconds-since-midnight) — completely timezone-independent.
	 * strtotime() must NOT be used here because it reads the server's local timezone,
	 * which would shift slot times if the server is not in UTC.
	 *
	 * @return array<int,array{start:string,end:string}>
	 */
	private function generate_raw_slots(
		string $open_time,
		string $close_time,
		int    $duration_min,
		int    $buffer_min
	): array {
		if ( $duration_min < 1 ) return [];

		$current = $this->time_to_seconds( $open_time );
		$close   = $this->time_to_seconds( $close_time );

		// '23:59' means "end of day" — add 60s so the final full slot is included.
		if ( '23:59' === substr( $close_time, 0, 5 ) ) {
			$close += 60;
		}

		// '00:00' close = midnight = treat as end-of-day when it's ≤ open time.
		if ( $close <= $current ) {
			$close += 86400;
		}

		$step  = ( $duration_min + $buffer_min ) * 60;
		$slots = [];

		while ( $current + $duration_min * 60 <= $close ) {
			$slots[] = [
				'start' => $this->seconds_to_time( $current ),
				'end'   => $this->seconds_to_time( $current + $duration_min * 60 ),
			];
			$current += $step;
		}

		return $slots;
	}

	/** Converts "HH:MM" or "HH:MM:SS" to seconds since midnight. Timezone-neutral. */
	private function time_to_seconds( string $time ): int {
		$parts = explode( ':', $time );
		return (int) $parts[0] * 3600 + (int) ( $parts[1] ?? 0 ) * 60;
	}

	/** Converts seconds-since-midnight to "HH:MM". Wraps at 24 h. */
	private function seconds_to_time( int $seconds ): string {
		$seconds = $seconds % 86400;
		return sprintf( '%02d:%02d', intdiv( $seconds, 3600 ), intdiv( $seconds % 3600, 60 ) );
	}

	/**
	 * Overlays booking / block statuses onto raw slots and attaches price.
	 * Priority: blocked > booked > available.
	 * Pending bookings count as booked — they are the lock.
	 * Same-day past/too-close slots are marked as 'booked'.
	 *
	 * @param  array<int,array{start:string,end:string}> $slots
	 * @param  int    $field_id
	 * @param  string $date       Y-m-d
	 * @param  object $field      Field DB row (for min_advance and base_price)
	 * @return array<int,array<string,mixed>>
	 */
	private function apply_statuses( array $slots, int $field_id, string $date, object $field, string $timezone = 'UTC' ): array {
		global $wpdb;

		$bookings = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT start_time, end_time, booked_slots FROM {$wpdb->prefix}playard_bookings
				 WHERE field_id = %d AND booking_date = %s
				 AND booking_status IN ('confirmed', 'pending', 'completed')",
				$field_id,
				$date
			)
		) ?: [];

		$blocks = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT start_time, end_time FROM {$wpdb->prefix}playard_blocked_slots
				 WHERE field_id = %d AND blocked_date = %s",
				$field_id,
				$date
			)
		) ?: [];

		$tz          = new DateTimeZone( $timezone ?: 'UTC' );
		$now_dt      = new DateTime( 'now', $tz );
		$today       = $now_dt->format( 'Y-m-d' );
		$now_ts      = $now_dt->getTimestamp();
		$min_advance = (int) ( $field->min_advance ?? 0 );
		$base_price  = (float) ( $field->base_price ?? 0 );

		foreach ( $slots as &$slot ) {
			$slot['status'] = 'available';
			$slot['price']  = (float) apply_filters(
				'playard_slot_price',
				$base_price,
				$field_id,
				$date,
				$slot['start']
			);

			// Priority 1: manual block
			foreach ( $blocks as $block ) {
				$b_start = substr( (string) $block->start_time, 0, 5 );
				$b_end   = substr( (string) $block->end_time,   0, 5 );
				if ( $slot['start'] < $b_end && $slot['end'] > $b_start ) {
					$slot['status'] = 'blocked';
					break;
				}
			}

			// Priority 2: confirmed / pending / completed booking
			if ( $slot['status'] === 'available' ) {
				foreach ( $bookings as $booking ) {
					$individual = ! empty( $booking->booked_slots )
						? json_decode( (string) $booking->booked_slots, true )
						: null;

					if ( is_array( $individual ) && count( $individual ) > 0 ) {
						// Use exact slot start times — avoids falsely marking gap slots as booked.
						foreach ( $individual as $b_slot_start ) {
							$b_start = substr( (string) $b_slot_start, 0, 5 );
							if ( $slot['start'] === $b_start ) {
								$slot['status'] = 'booked';
								break 2;
							}
						}
					} else {
						// Legacy bookings without booked_slots: fall back to range overlap.
						$b_start = substr( (string) $booking->start_time, 0, 5 );
						$b_end   = substr( (string) $booking->end_time,   0, 5 );
						if ( $slot['start'] < $b_end && $slot['end'] > $b_start ) {
							$slot['status'] = 'booked';
							break;
						}
					}
				}
			}

			// Priority 3: same-day slots too close to now (min_advance in hours)
			if ( $slot['status'] === 'available' && $date === $today ) {
				$slot_ts = ( new DateTime( $date . ' ' . $slot['start'] . ':00', $tz ) )->getTimestamp();
				if ( $slot_ts < $now_ts + $min_advance * HOUR_IN_SECONDS ) {
					$slot['status'] = 'booked';
				}
			}
		}

		unset( $slot );

		return apply_filters( 'playard_day_slots', $slots, $field_id, $date );
	}

	/**
	 * Calculates the end time given a start time and duration in minutes.
	 * Uses pure arithmetic — timezone-neutral.
	 */
	public function calculate_end_time( string $start_time, int $duration_min ): string {
		return $this->seconds_to_time( $this->time_to_seconds( $start_time ) + $duration_min * 60 );
	}

	/**
	 * Sums prices for each slot start time using the field's base price + filters.
	 */
	public function calculate_total_for_slots( object $field, string $date, array $slots ): float {
		$total    = 0.0;
		$field_id = (int) $field->id;

		foreach ( $slots as $start_time ) {
			$total += $this->get_slot_price( $field_id, $date, (string) $start_time );
		}

		return $total;
	}

	/**
	 * Returns true if the given slots are in consecutive order (no gaps).
	 * Step = (slot_duration + buffer_time) × 60 seconds.
	 * Single-slot arrays always pass.
	 *
	 * @param  array $slots     Array of HH:MM start times
	 * @param  int   $field_id
	 */
	public function validate_consecutive_slots( array $slots, int $field_id ): bool {
		if ( count( $slots ) <= 1 ) {
			return true;
		}

		$field = playard_get_field( $field_id );
		if ( ! $field ) {
			return false;
		}

		$step = ( (int) $field->slot_duration + (int) $field->buffer_time ) * 60;

		sort( $slots );

		for ( $i = 1, $total = count( $slots ); $i < $total; $i++ ) {
			$prev_ts = $this->time_to_seconds( $slots[ $i - 1 ] );
			$curr_ts = $this->time_to_seconds( $slots[ $i ] );

			if ( $curr_ts - $prev_ts !== $step ) {
				return false;
			}
		}

		return true;
	}

	// ── Availability ──────────────────────────────────────────────────────────────

	/**
	 * Returns true if no confirmed/pending booking overlaps the given time window.
	 * Called by booking creation before INSERT to prevent double booking.
	 *
	 * @param int    $field_id
	 * @param string $date   Y-m-d
	 * @param string $start  HH:MM  (slot start)
	 * @param string $end    HH:MM  (slot end)
	 */
	public function is_slot_available( int $field_id, string $date, string $start, string $end ): bool {
		global $wpdb;

		$count = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->prefix}playard_bookings
				 WHERE field_id = %d
				 AND booking_date = %s
				 AND start_time < %s
				 AND end_time > %s
				 AND booking_status IN ('confirmed', 'pending', 'completed')",
				$field_id,
				$date,
				$end,
				$start
			)
		);

		return $count === 0;
	}

	/**
	 * Returns the price for a slot on a given field / date / time.
	 * Base price comes from the field record.
	 * Pro plugin hooks playard_slot_price to add peak / weekend pricing.
	 *
	 * @param int    $field_id
	 * @param string $date        Y-m-d
	 * @param string $start_time  HH:MM
	 */
	public function get_slot_price( int $field_id, string $date, string $start_time ): float {
		global $wpdb;

		$field = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT base_price FROM {$wpdb->prefix}playard_fields WHERE id = %d LIMIT 1",
				$field_id
			)
		);

		$price = $field ? (float) $field->base_price : 0.0;

		return (float) apply_filters( 'playard_slot_price', $price, $field_id, $date, $start_time );
	}

	// ── Cron cleanup ─────────────────────────────────────────────────────────────

	/**
	 * Deletes stale pending bookings that were never paid (browser closed, network error, etc.).
	 * Runs every 5 minutes via WP-Cron.
	 * Only targets Stripe-pending bookings older than 10 minutes.
	 * Cash bookings with status='pending' are owner-managed and never auto-deleted.
	 */
	public static function cleanup_stale_bookings(): void {
		global $wpdb;

		$deleted = (int) $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->prefix}playard_bookings
				 WHERE booking_status  = 'pending'
				 AND   payment_status  = 'pending'
				 AND   payment_gateway != 'cash'
				 AND   created_at      < %s",
				gmdate( 'Y-m-d H:i:s', time() - 600 )
			)
		);

		if ( $deleted > 0 ) {
			do_action( 'playard_stale_bookings_cleaned', $deleted );
		}
	}

	/**
	 * Deletes expired OTP rows. Runs hourly via WP-Cron.
	 */
	public static function cleanup_expired_otp(): void {
		global $wpdb;

		// OTP expires_at is stored as UTC via gmdate(); compare with UTC now.
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->prefix}playard_otp WHERE expires_at < %s",
				gmdate( 'Y-m-d H:i:s' )
			)
		);
	}

	/**
	 * Returns the step between slot start times in seconds.
	 * Step = (slot_duration + buffer_time) × 60.
	 * The buffer is invisible to the customer — only slot_duration is shown as the booking length.
	 *
	 * @param array<string,mixed> $field  Requires slot_duration (min) and buffer_time (min).
	 */
	public function get_slot_step( array $field ): int {
		$duration = absint( $field['slot_duration'] ?? 60 );
		$buffer   = absint( $field['buffer_time']   ?? 0 );
		return ( $duration + $buffer ) * 60;
	}

	/**
	 * Returns true if $date falls within the field's advance booking window.
	 * Used to grey out dates in the booking calendar.
	 *
	 * @param string              $date   Y-m-d
	 * @param array<string,mixed> $field  Requires min_advance (hours) and max_advance (days).
	 */
	public function is_bookable_date( string $date, array $field ): bool {
		$min_advance = absint( $field['min_advance'] ?? 0 );
		$max_advance = absint( $field['max_advance'] ?? 0 );
		$tz          = playard_facility_tz();
		$now_dt      = new DateTime( 'now', $tz );

		// min_advance: earliest bookable calendar date in facility timezone.
		if ( $min_advance > 0 ) {
			$min_date = ( clone $now_dt )->modify( "+{$min_advance} hours" )->format( 'Y-m-d' );
			if ( $date < $min_date ) {
				return false;
			}
		}

		// max_advance: latest bookable calendar date in facility timezone (0 = no limit).
		if ( $max_advance > 0 ) {
			$max_date = ( clone $now_dt )->modify( "+{$max_advance} days" )->format( 'Y-m-d' );
			if ( $date > $max_date ) {
				return false;
			}
		}

		return true;
	}
}
