<?php

namespace SugarCalendarBookings\Availability;

use DateTimeImmutable;
use SugarCalendarBookings\Appointments\AppointmentModel;
use SugarCalendarBookings\Employees\EmployeeModel;
use SugarCalendarBookings\Integrations\AbstractOAuthIntegration;
use SugarCalendarBookings\Integrations\CalendarConflictProviderInterface;
use SugarCalendarBookings\Integrations\ExternalBusyTimes\ExternalBusyTimeConflictChecker;
use SugarCalendarBookings\Integrations\IntegrationCapabilityRegistry;
use SugarCalendarBookings\Locations\AbstractLocation;
use SugarCalendarBookings\Plugin;
use SugarCalendarBookings\Services\ServiceModel;

/**
 * Availability service.
 *
 * @since 1.0.0
 */
class AvailabilityService {

	/**
	 * Check if a slot is available.
	 *
	 * @since 1.0.0
	 *
	 * @param DateTimeImmutable $start_at             Start time.
	 * @param DateTimeImmutable $end_at               End time.
	 * @param ServiceModel      $service              Service model.
	 * @param AbstractLocation  $location             Location model.
	 * @param EmployeeModel     $employee             Employee model.
	 * @param array             $exclude_appointments Array of appointment IDs to exclude.
	 *
	 * @return bool True if slot is available.
	 */
	public function is_slot_available( $start_at, $end_at, $service, $location, $employee, $exclude_appointments = [] ) {

		$duration = ( $end_at->getTimestamp() - $start_at->getTimestamp() ) / 60;

		$slots = $this->get_slots( $start_at, $end_at, $service, $location, $employee, $duration, $exclude_appointments );

		/*
		 * Current version only allows to book slots for the exact slots step.
		 * If we'll need to support custom start time it should be adjusted.
		 */
		foreach ( $slots as $slot ) {
			if (
				$slot->is_available() &&
				$slot->start()->getTimestamp() === $start_at->getTimestamp() &&
				$slot->end()->getTimestamp() === $end_at->getTimestamp()
			) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get all availability slots between two dates.
	 *
	 * @since 1.0.0
	 * @since 1.3.1 Added $skip_advance_window parameter.
	 *
	 * @param DateTimeImmutable $from_date            Start date.
	 * @param DateTimeImmutable $to_date              End date.
	 * @param ServiceModel      $service              Service model.
	 * @param AbstractLocation  $location             Location.
	 * @param EmployeeModel     $employee             Employee model.
	 * @param int               $duration             Slot duration in minutes (by default, the service duration is used).
	 * @param array             $exclude_appointments Array of appointment IDs to exclude.
	 * @param bool              $skip_advance_window  Whether to skip the advance window validation.
	 *
	 * @return array<AvailabilityTimeSlot> Array of availability time slots.
	 */
	public function get_slots( $from_date, $to_date, $service, $location, $employee, $duration = null, $exclude_appointments = [], $skip_advance_window = false ) {

		$duration = $duration ?? $service->duration;

		if ( $duration <= 0 ) {
			return [];
		}

		$query = AppointmentModel::query()
			->with( [ 'employee' => fn( $q ) => $q->select( [ 'id' ] ) ] )
			->where( 'appointments.employee_id', '=', $employee->id, '%d' )
			->where( 'appointments.start_at', '>=', $this->format_date( $from_date ) . ' 00:00:00', '%s' )
			->where( 'appointments.end_at', '<=', $this->format_date( $to_date ) . ' 23:59:59', '%s' )
			->where( 'appointments.status', 'IN', [ 'approved', 'pending' ], '%s' )
			->group_by( [ 'appointments.id' ] );

		if ( ! empty( $exclude_appointments ) ) {
			$query->where( 'appointments.id', 'NOT IN', $exclude_appointments, '%d' );
		}

		$appointments = $query->get();

		// TODO: Make slot step configurable via settings.
		$slot_step = 15;

		$working_periods = Plugin::get( WorkingScheduleService::class )->get_working_periods(
			$from_date,
			$to_date,
			$service,
			$location->get_model(),
			$employee
		);

		$working_slots = $this->generate_working_slots( $working_periods, $duration, $slot_step );

		// Pre-fetch external busy times for the entire date range.
		$external_busy_times = $this->get_external_busy_times( $working_slots, $from_date, $to_date );

		/*
		 * Avalibility check should be done after all slots are generated to
		 * ensure that slots spanning day boundaries in UTC are checked correctly.
		 */
		$slots = $this->get_availability_slots( $working_slots, $appointments, $skip_advance_window, $external_busy_times );

		return $slots;
	}

	/**
	 * Get all availability slots for a specific date.
	 *
	 * @since 1.0.0
	 *
	 * @param array<WorkingPeriod> $periods       Array of periods for the date.
	 * @param int                  $slot_duration Slot duration in minutes.
	 * @param int                  $slot_step     Slot step in minutes.
	 *
	 * @return array<WorkingTimeSlot> Array of time slots.
	 */
	private function generate_working_slots( $periods, $slot_duration, $slot_step ) {

		// Prevent infinite loop.
		if ( $slot_step <= 0 ) {
			return [];
		}

		$slots = [];

		foreach ( $periods as $period ) {
			$period_start = $period->start();
			$period_end   = $period->end();

			$slot_start = $period_start;

			while ( true ) {
				$slot = WorkingTimeSlot::from_duration( $slot_start, $slot_duration, $period->get_employees() );

				$slot_end = $slot->end();

				if ( $slot_end > $period_end ) {
					break;
				}

				$slots[] = $slot;

				$slot_start = $slot_start->modify( '+' . $slot_step . ' minutes' );
			}
		}

		return $slots;
	}

	/**
	 * Get availability slots.
	 *
	 * @since 1.0.0
	 * @since 1.3.1 Added $skip_advance_window parameter to conditionally bypass advance window validation.
	 *
	 * @param array<WorkingTimeSlot> $working_slots       Array of time slots.
	 * @param array                  $appointments        Array of appointments.
	 * @param bool                   $skip_advance_window Whether to skip the advance window validation.
	 * @param array                  $external_busy_times Pre-fetched external busy times grouped by employee ID.
	 *
	 * @return array<AvailabilityTimeSlot> Array of availability time slots.
	 */
	private function get_availability_slots( $working_slots, $appointments, $skip_advance_window = false, $external_busy_times = [] ) {

		$availability_slots       = [];
		$advance_window_validator = new AdvanceWindowValidator();

		// Group appointments by date and employee for faster lookup.
		$appointments = $this->group_appointments( $appointments );

		foreach ( $working_slots as $slot ) {
			$available_employees = $this->get_slot_available_employees( $slot, $appointments, $external_busy_times );
			$is_available        = true;

			$enforce_advance_window = ! $skip_advance_window;

			if (
				empty( $available_employees ) ||
				( $enforce_advance_window && $advance_window_validator->is_slot_outside_advance_window( $slot ) )
			) {
				$is_available = false;
			}

			$availability_slots[] = new AvailabilityTimeSlot( $slot->start(), $slot->end(), $is_available, $available_employees );
		}

		return $availability_slots;
	}

	/**
	 * Get available employees for a slot.
	 *
	 * @since 1.0.0
	 *
	 * @param WorkingTimeSlot $working_slot        Working time slot.
	 * @param array           $appointments        Array of appointments by date.
	 * @param array           $external_busy_times Pre-fetched external busy times grouped by employee ID.
	 *
	 * @return array<EmployeeModel> Array of available employees.
	 */
	protected function get_slot_available_employees( $working_slot, $appointments, $external_busy_times = [] ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$appointments_by_employee = $appointments[ $this->format_date( $working_slot->start() ) ] ?? [];

		if ( empty( $appointments_by_employee ) ) {
			return $this->filter_by_external_conflicts( $working_slot->get_employees(), $working_slot, $external_busy_times );
		}

		$employees = [];

		foreach ( $working_slot->get_employees() as $employee ) {
			$is_available = true;

			foreach ( $appointments_by_employee[ $employee->id ] ?? [] as $appointment ) {
				if (
					$working_slot->start() < $appointment->end_at &&
					$working_slot->end() > $appointment->start_at
				) {
					$is_available = false;

					break;
				}
			}

			if ( $is_available ) {
				$employees[] = $employee;
			}
		}

		return $this->filter_by_external_conflicts( $employees, $working_slot, $external_busy_times );
	}

	/**
	 * Pre-fetch external busy times for the date range via conflict checker.
	 *
	 * @since 1.4.0
	 *
	 * @param array<WorkingTimeSlot> $working_slots Working time slots.
	 * @param DateTimeImmutable      $from_date     Start date.
	 * @param DateTimeImmutable      $to_date       End date.
	 *
	 * @return array Pre-fetched busy times grouped by employee ID.
	 */
	private function get_external_busy_times( array $working_slots, DateTimeImmutable $from_date, DateTimeImmutable $to_date ): array {

		$employee_ids = [];

		foreach ( $working_slots as $slot ) {
			foreach ( $slot->get_employees() as $employee ) {
				if ( $employee && $employee->id ) {
					$employee_ids[ $employee->id ] = true;
				}
			}
		}

		$employee_ids = array_keys( $employee_ids );

		$providers = Plugin::get( IntegrationCapabilityRegistry::class )
			->get_available( CalendarConflictProviderInterface::class );

		$provider_slugs  = [];
		$global_user_ids = [];

		foreach ( $providers as $provider ) {
			$slug                     = $provider->get_provider_slug();
			$provider_slugs[]         = $slug;
			$global_user_ids[ $slug ] = $provider instanceof AbstractOAuthIntegration
				? $provider->get_global_account_user_ids()
				: [];
		}

		if ( empty( $provider_slugs ) ) {
			return [];
		}

		return Plugin::get( ExternalBusyTimeConflictChecker::class )->prefetch( $employee_ids, $from_date, $to_date, $provider_slugs, $global_user_ids );
	}

	/**
	 * Filter employees by external busy time conflicts.
	 *
	 * @since 1.4.0
	 *
	 * @param array<EmployeeModel> $employees           Available employees.
	 * @param WorkingTimeSlot      $working_slot        Working time slot.
	 * @param array                $external_busy_times Pre-fetched busy times grouped by employee ID.
	 *
	 * @return array<EmployeeModel> Filtered employees.
	 */
	private function filter_by_external_conflicts( array $employees, WorkingTimeSlot $working_slot, array $external_busy_times ): array {

		if ( empty( $employees ) || empty( $external_busy_times ) ) {
			return $employees;
		}

		return Plugin::get( ExternalBusyTimeConflictChecker::class )->filter_employees(
			$employees,
			$working_slot->start()->getTimestamp(),
			$working_slot->end()->getTimestamp(),
			$external_busy_times
		);
	}

	/**
	 * Group appointments by date and employee for faster lookup.
	 *
	 * @since 1.0.0
	 *
	 * @param array<AppointmentModel> $appointments Array of appointments.
	 *
	 * @return array<string, array<AppointmentModel>> Array of appointments by date.
	 */
	private function group_appointments( $appointments ) {

		$result = [];

		foreach ( $appointments as $appointment ) {
			$start_at    = $appointment->start_at->setTime( 0, 0 );
			$end_at      = $appointment->end_at->setTime( 0, 0 );
			$employee_id = $appointment->employee->id;

			// Loop through each date the appointment covers.
			for ( $date = $start_at; $date <= $end_at; $date = $date->modify( '+1 day' ) ) {
				$result[ $date->format( 'Y-m-d' ) ][ $employee_id ][] = $appointment;
			}
		}

		return $result;
	}

	/**
	 * Convert DateTime to Y-m-d format.
	 *
	 * @since 1.0.0
	 *
	 * @param DateTimeImmutable $date DateTimeImmutable object.
	 *
	 * @return string Date in Y-m-d format.
	 */
	private function format_date( $date ) {

		return $date->format( 'Y-m-d' );
	}
}
