<?php

namespace SugarCalendarBookings\Calendar;

use DateTimeImmutable;
use SugarCalendarBookings\Availability\WorkingPeriod;
use SugarCalendarBookings\Availability\WorkingScheduleService;
use SugarCalendarBookings\Availability\WorkingScheduleDateRangeRule;
use SugarCalendarBookings\Availability\WorkingScheduleWeekdayRule;
use SugarCalendarBookings\Services\ServiceModel;
use SugarCalendarBookings\Support\Helpers\Time;
use SugarCalendarBookings\Plugin;

/**
 * Computes display hour bounds for week/day calendar grid.
 *
 * Uses Template Method pattern: Pro overrides compute_extensions()
 * to add employee schedule support.
 *
 * @since 1.4.0
 */
class CalendarDisplayRangeService {

	/**
	 * Total minutes in a day.
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	const MINUTES_PER_DAY = 1440;

	/**
	 * Buffer in minutes applied before start and after end of the range.
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	const BUFFER_MINUTES = 120;

	/**
	 * Fallback start time (minutes from midnight), e.g. 540 = 09:00.
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	const FALLBACK_START = 540;

	/**
	 * Fallback end time (minutes from midnight), e.g. 1020 = 17:00.
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	const FALLBACK_END = 1020;

	/**
	 * Maximum display hour in minutes (23:00).
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	const MAX_DISPLAY_HOUR = 1380;

	/**
	 * Get display bounds for the given context.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 *
	 * @return DisplayBounds
	 */
	public function get_bounds( DisplayBoundsContext $context ): DisplayBounds {

		if ( empty( $context->dates ) ) {
			return DisplayBounds::fallback();
		}

		$min = null;
		$max = null;

		$this->compute_extensions( $context, $min, $max );

		// Always extend from global date overrides (holidays, custom days).
		$this->extend_from_override_rules(
			$context,
			Plugin::get( WorkingScheduleService::class )->get_default_override_rules(),
			$min,
			$max
		);

		if ( $min === null || $max === null ) {
			$this->apply_fallback( $context, $min, $max );
		}

		return $this->apply_buffer_and_clamp(
			$min ?? self::FALLBACK_START,
			$max ?? self::FALLBACK_END
		);
	}

	/**
	 * Template method — defines which sources contribute bounds.
	 *
	 * Pro overrides this to add employee schedule logic.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 * @param int|null             $min     Running minimum (minutes from midnight).
	 * @param int|null             $max     Running maximum (minutes from midnight).
	 */
	protected function compute_extensions( DisplayBoundsContext $context, ?int &$min, ?int &$max ): void {

		// On unfiltered calendars, seed bounds from the regular schedule so
		// appointments extend the working day instead of replacing it.
		if ( $context->query->get_service_id() === '' ) {
			$this->extend_from_regular_rules( $context, $min, $max );
		}

		$this->extend_from_service( $context, $min, $max );

		// Always let appointments widen the bounds so out-of-schedule
		// entries stay within the rendered grid.
		$this->extend_from_appointments( $context, $min, $max );
	}

	/**
	 * Extend min/max from appointments.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 * @param int|null             $min     Running minimum.
	 * @param int|null             $max     Running maximum.
	 */
	protected function extend_from_appointments( DisplayBoundsContext $context, ?int &$min, ?int &$max ): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$date_strings = array_map( fn( $d ) => $d->format( 'Y-m-d' ), $context->dates );

		foreach ( $context->appointments as $appointment ) {
			if ( ! isset( $appointment->start_at, $appointment->end_at ) ) {
				continue;
			}

			$start_dt = $appointment->start_at->setTimezone( $context->display_tz );
			$end_dt   = $appointment->end_at->setTimezone( $context->display_tz );

			// If appointment crosses midnight, only include it for the END day (booking date).
			if ( $this->crosses_midnight( $start_dt, $end_dt ) ) {

				if ( ! in_array( $end_dt->format( 'Y-m-d' ), $date_strings, true ) ) {
					continue;
				}

				// For the end day, use midnight as the effective start.
				$start_dt = $end_dt->setTime( 0, 0, 0 );
			} elseif ( ! in_array( $start_dt->format( 'Y-m-d' ), $date_strings, true ) ) {
				continue;
			}

			$start_m = Time::minutes_from_midnight( $start_dt );
			$end_m   = Time::minutes_from_midnight( $end_dt );

			if ( $this->crosses_midnight( $start_dt, $end_dt ) ) {
				$end_m = self::MINUTES_PER_DAY;
			}

			$this->extend( $start_m, $end_m, $min, $max );
		}
	}

	/**
	 * Extend min/max from service working periods.
	 *
	 * Only runs when a service is selected in the query.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 * @param int|null             $min     Running minimum.
	 * @param int|null             $max     Running maximum.
	 */
	protected function extend_from_service( DisplayBoundsContext $context, ?int &$min, ?int &$max ): void {

		if ( $context->query->get_service_id() === '' ) {
			return;
		}

		$service = $this->resolve_service( $context );

		if ( ! $service ) {
			return;
		}

		$periods = Plugin::get( WorkingScheduleService::class )->get_working_periods(
			min( $context->dates ),
			max( $context->dates ),
			$service
		);

		$this->extend_from_periods( $context, $periods, $min, $max );
	}

	/**
	 * Extend min/max from working periods by converting to display timezone.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 * @param WorkingPeriod[]      $periods Working periods (start/end in UTC).
	 * @param int|null             $min     Running minimum.
	 * @param int|null             $max     Running maximum.
	 */
	protected function extend_from_periods( DisplayBoundsContext $context, array $periods, ?int &$min, ?int &$max ): void {

		$date_strings = array_map( fn( $d ) => $d->format( 'Y-m-d' ), $context->dates );

		foreach ( $periods as $period ) {

			if ( ! $period instanceof WorkingPeriod ) {
				continue;
			}

			$start_dt = $period->start()->setTimezone( $context->display_tz );
			$end_dt   = $period->end()->setTimezone( $context->display_tz );

			// Only extend from periods whose date is in the requested dates array.
			if ( ! in_array( $start_dt->format( 'Y-m-d' ), $date_strings, true ) ) {
				continue;
			}

			$start_m = Time::minutes_from_midnight( $start_dt );
			$end_m   = Time::minutes_from_midnight( $end_dt );

			if ( $this->crosses_midnight( $start_dt, $end_dt ) ) {
				$end_m = self::MINUTES_PER_DAY;
			}

			$this->extend( $start_m, $end_m, $min, $max );
		}
	}

	/**
	 * Widen min/max accumulators with the given start and end minutes.
	 *
	 * @since 1.4.0
	 *
	 * @param int      $start_minutes Start minutes from midnight.
	 * @param int      $end_minutes   End minutes from midnight.
	 * @param int|null $min           Running minimum.
	 * @param int|null $max           Running maximum.
	 */
	protected function extend( int $start_minutes, int $end_minutes, ?int &$min, ?int &$max ): void {

		if ( $min === null || $start_minutes < $min ) {
			$min = $start_minutes;
		}

		if ( $max === null || $end_minutes > $max ) {
			$max = $end_minutes;
		}
	}

	/**
	 * Cached resolved services keyed by service ID.
	 *
	 * @since 1.4.0
	 *
	 * @var array<string, ServiceModel|null>
	 */
	private $resolved_services = [];

	/**
	 * Resolve service model for the query (selected service or first available).
	 *
	 * Results are cached per service ID to avoid duplicate DB queries
	 * when called multiple times within the same request.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 *
	 * @return ServiceModel|null
	 */
	protected function resolve_service( DisplayBoundsContext $context ) {

		$service_id = $context->query->get_service_id();
		$cache_key  = $service_id !== '' ? $service_id : '_first';

		if ( array_key_exists( $cache_key, $this->resolved_services ) ) {
			return $this->resolved_services[ $cache_key ];
		}

		if ( $service_id !== '' ) {
			$service = ServiceModel::query()->where( 'id', '=', (int) $service_id, '%d' )->first();
		} else {
			$service = ServiceModel::query()->order_by( 'id', 'asc' )->first();
		}

		$this->resolved_services[ $cache_key ] = $service;

		return $service;
	}

	/**
	 * Extend min/max from the global regular working schedule rules
	 * that apply to any of the visible weekdays.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 * @param int|null             $min     Running minimum.
	 * @param int|null             $max     Running maximum.
	 */
	protected function extend_from_regular_rules( DisplayBoundsContext $context, ?int &$min, ?int &$max ): void {

		$weekdays = array_unique( array_map( fn( $date ) => (int) $date->format( 'N' ), $context->dates ) );

		foreach ( Plugin::get( WorkingScheduleService::class )->get_default_regular_rules() as $rule ) {
			if ( $rule instanceof WorkingScheduleWeekdayRule && ! in_array( (int) $rule->get_weekday(), $weekdays, true ) ) {
				continue;
			}

			foreach ( $rule->get_periods() as $period ) {
				$this->extend( $period->start(), $period->end(), $min, $max );
			}
		}
	}

	/**
	 * Apply fallback bounds when no extension sources produced results.
	 *
	 * Falls back to the global regular schedule, then to hardcoded
	 * 09:00-17:00 defaults.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 * @param int|null             $min     Running minimum.
	 * @param int|null             $max     Running maximum.
	 */
	private function apply_fallback( DisplayBoundsContext $context, ?int &$min, ?int &$max ): void {

		if ( $min !== null && $max !== null ) {
			return;
		}

		$this->extend_from_regular_rules( $context, $min, $max );

		if ( $min === null || $max === null ) {
			$min = self::FALLBACK_START;
			$max = self::FALLBACK_END;
		}
	}

	/**
	 * Extend min/max from date override rules that overlap the visible dates.
	 *
	 * @since 1.4.0
	 *
	 * @param DisplayBoundsContext $context Computation context.
	 * @param array                $rules   Override rules.
	 * @param int|null             $min     Running minimum.
	 * @param int|null             $max     Running maximum.
	 */
	private function extend_from_override_rules( DisplayBoundsContext $context, array $rules, ?int &$min, ?int &$max ): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$date_strings = array_map( fn( $d ) => $d->format( 'Y-m-d' ), $context->dates );

		foreach ( $rules as $rule ) {
			if ( ! $rule instanceof WorkingScheduleDateRangeRule ) {
				continue;
			}

			// Check if any visible date falls within the override's date range.
			$override_start = $rule->get_start_date()->format( 'Y-m-d' );
			$override_end   = $rule->get_end_date()->format( 'Y-m-d' );

			$overlaps = false;

			foreach ( $date_strings as $date_string ) {
				if ( $date_string >= $override_start && $date_string <= $override_end ) {
					$overlaps = true;

					break;
				}
			}

			if ( ! $overlaps ) {
				continue;
			}

			foreach ( $rule->get_periods() as $period ) {
				$this->extend( $period->start(), $period->end(), $min, $max );
			}
		}
	}

	/**
	 * Apply buffer, round to full hours, and clamp to calendar display limits.
	 *
	 * @since 1.4.0
	 *
	 * @param int $start_minutes Raw start minutes.
	 * @param int $end_minutes   Raw end minutes.
	 *
	 * @return DisplayBounds
	 */
	private function apply_buffer_and_clamp( int $start_minutes, int $end_minutes ): DisplayBounds {

		$start_minutes = max( 0, min( self::MINUTES_PER_DAY, $start_minutes - self::BUFFER_MINUTES ) );
		$end_minutes   = max( 0, min( self::MINUTES_PER_DAY, $end_minutes + self::BUFFER_MINUTES ) );

		// Round to full hours.
		$start_minutes = (int) floor( $start_minutes / 60 ) * 60;
		$end_minutes   = (int) ceil( $end_minutes / 60 ) * 60;

		// Clamp to calendar display limits: minimum 00:00 (0), maximum 23:00 (1380).
		$start_minutes = max( 0, $start_minutes );
		$end_minutes   = min( self::MAX_DISPLAY_HOUR, $end_minutes );

		// Ensure start does not exceed end.
		$start_minutes = min( $start_minutes, $end_minutes );

		return new DisplayBounds(
			[
				'start' => $start_minutes,
				'end'   => $end_minutes,
			]
		);
	}

	/**
	 * Check if an end datetime falls on a different date than the start datetime.
	 *
	 * @since 1.4.0
	 *
	 * @param DateTimeImmutable $start_dt Start datetime.
	 * @param DateTimeImmutable $end_dt   End datetime.
	 *
	 * @return bool
	 */
	private function crosses_midnight( DateTimeImmutable $start_dt, DateTimeImmutable $end_dt ): bool {

		return $end_dt->format( 'Y-m-d' ) !== $start_dt->format( 'Y-m-d' );
	}
}
