<?php
/**
 * @package Hafenstudios\Kurato
 */

namespace Hafenstudios\Kurato\Booking;

defined( 'ABSPATH' ) || exit;

/**
 * Berechnet buchbare Terminslots (K5a, K5-KONZEPT Abschnitt 3).
 *
 * REIN: kein wpdb-, HTTP- oder Settings-Zugriff. Alle Daten kommen als Parameter,
 * damit die Logik ohne WordPress testbar ist. Der Aufrufer (K5c) laedt Terminart,
 * Wochenraster, Ausnahmen und bestehende Buchungen aus den Repositories und
 * uebergibt sie hier.
 *
 * Zeit-Konventionen:
 * - Ein-/Ausgabe-Instants sind UTC (DateTimeImmutable). Der Rueckgabewert ist eine
 *   Liste von Slot-Startzeiten als DateTimeImmutable in UTC, aufsteigend sortiert.
 * - Das Wochenraster und die Ausnahmen tragen Site-Zeitzonen-Wanduhrzeiten; die
 *   Umrechnung nach UTC laeuft ueber DateTimeZone (nie Offset-Arithmetik), damit
 *   Sommer-/Winterzeit korrekt behandelt wird. Nicht existierende Wanduhrzeiten in
 *   der Umstellungsnacht (Fruehjahr, Luecke 02:00->03:00 in Europe/Berlin) werden
 *   uebersprungen; in der doppelten Herbststunde entsteht je Wanduhrzeit genau ein
 *   Slot (PHP loest die Zweideutigkeit auf einen Instant auf).
 * - Wochentag-Konvention: 0=Sonntag .. 6=Samstag (wie PHP date('w')).
 *
 * Abzuege: Vorlauf (jetzt + lead_time_hours), Horizont (heute 00:00 Site +
 * horizon_days, obere Grenze exklusiv), Ausnahmen (ganztaegig/teiltaegig) und
 * bestehende confirmed-Buchungen inklusive Puffer. Beim Puffer werden sowohl die
 * Puffer der bestehenden Buchung als auch die der neuen Terminart beruecksichtigt
 * (jede Buchung reserviert ihren eigenen Puffer; die noetige Luecke ist damit die
 * Summe - konservativ, verhindert Kollisionen sicher).
 */
final class SlotCalculator {

	/**
	 * Berechnet die buchbaren Slot-Startzeiten (UTC) fuer ein Zeitfenster.
	 *
	 * @param array<string, mixed>              $type       Terminart: duration_min,
	 *      buffer_before_min, buffer_after_min, lead_time_hours, horizon_days.
	 * @param array<int, array<string, mixed>>  $weekly     Wochenraster-Fenster:
	 *      je Eintrag weekday (0-6), start_time, end_time ('H:i' oder 'H:i:s', Site).
	 * @param array<int, array<string, mixed>>  $exceptions Ausnahmen: date_from,
	 *      date_to ('Y-m-d', Site), optional start_time/end_time (teiltaegig).
	 * @param array<int, array<string, mixed>>  $bookings   Bestehende Buchungen:
	 *      start_utc, end_utc (UTC-String oder DateTimeInterface), buffer_before_min,
	 *      buffer_after_min.
	 * @param \DateTimeImmutable                $window_from Fensteranfang (UTC).
	 * @param \DateTimeImmutable                $window_to   Fensterende (UTC, exklusiv).
	 * @param \DateTimeImmutable                $now         Jetzt (UTC).
	 * @param \DateTimeZone                     $site_tz     Site-Zeitzone.
	 * @return array<int, \DateTimeImmutable> Slot-Startzeiten (UTC), aufsteigend.
	 */
	public function available_slots(
		array $type,
		array $weekly,
		array $exceptions,
		array $bookings,
		\DateTimeImmutable $window_from,
		\DateTimeImmutable $window_to,
		\DateTimeImmutable $now,
		\DateTimeZone $site_tz
	): array {
		$utc      = new \DateTimeZone( 'UTC' );
		$duration = max( 1, (int) ( $type['duration_min'] ?? 0 ) );
		$buf_before_new = max( 0, (int) ( $type['buffer_before_min'] ?? 0 ) );
		$buf_after_new  = max( 0, (int) ( $type['buffer_after_min'] ?? 0 ) );

		$window_from = $window_from->setTimezone( $utc );
		$window_to   = $window_to->setTimezone( $utc );
		$now         = $now->setTimezone( $utc );

		if ( $window_to <= $window_from || array() === $weekly ) {
			return array();
		}

		// Vorlauf: frueheste Startzeit = max( Fensteranfang, jetzt + lead ).
		$lead_hours     = max( 0, (int) ( $type['lead_time_hours'] ?? 0 ) );
		$earliest_start = $now->add( new \DateInterval( 'PT' . $lead_hours . 'H' ) );
		if ( $earliest_start < $window_from ) {
			$earliest_start = $window_from;
		}

		// Horizont: heute 00:00 (Site) + horizon_days, obere Grenze exklusiv.
		$horizon_days   = max( 0, (int) ( $type['horizon_days'] ?? 0 ) );
		$today_site     = \DateTimeImmutable::createFromFormat( '!Y-m-d', $now->setTimezone( $site_tz )->format( 'Y-m-d' ), $site_tz );
		$horizon_cutoff = $today_site->add( new \DateInterval( 'P' . $horizon_days . 'D' ) )->setTimezone( $utc );

		// Wochenraster nach Wochentag gruppieren.
		$by_weekday = array();
		foreach ( $weekly as $w ) {
			$wd = (int) ( $w['weekday'] ?? -1 );
			if ( $wd < 0 || $wd > 6 ) {
				continue;
			}
			$start_min = $this->time_to_minutes( (string) ( $w['start_time'] ?? '' ) );
			$end_min   = $this->time_to_minutes( (string) ( $w['end_time'] ?? '' ) );
			if ( null === $start_min || null === $end_min || $end_min <= $start_min ) {
				continue;
			}
			$by_weekday[ $wd ][] = array( $start_min, $end_min );
		}
		if ( array() === $by_weekday ) {
			return array();
		}

		// Bestehende Buchungen als (um Puffer geweitete) UTC-Sperrintervalle.
		$booking_blocks = array();
		foreach ( $bookings as $b ) {
			$b_start = $this->to_utc( $b['start_utc'] ?? null, $utc );
			$b_end   = $this->to_utc( $b['end_utc'] ?? null, $utc );
			if ( null === $b_start || null === $b_end ) {
				continue;
			}
			$bb = max( 0, (int) ( $b['buffer_before_min'] ?? 0 ) );
			$ba = max( 0, (int) ( $b['buffer_after_min'] ?? 0 ) );
			$booking_blocks[] = array(
				$b_start->sub( new \DateInterval( 'PT' . $bb . 'M' ) ),
				$b_end->add( new \DateInterval( 'PT' . $ba . 'M' ) ),
			);
		}

		$slots = array();

		// Tag fuer Tag durch das Fenster (in Site-Zeit), damit Wochentag und
		// Ausnahmen je Kalendertag stimmen.
		$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $window_from->setTimezone( $site_tz )->format( 'Y-m-d' ), $site_tz );
		$last_day = $window_to->setTimezone( $site_tz )->format( 'Y-m-d' );

		while ( $day->format( 'Y-m-d' ) <= $last_day ) {
			$date_str = $day->format( 'Y-m-d' );
			$weekday  = (int) $day->format( 'w' );

			if ( isset( $by_weekday[ $weekday ] ) ) {
				$day_blocks = $this->exception_blocks_for_day( $date_str, $exceptions, $site_tz, $utc );

				foreach ( $by_weekday[ $weekday ] as $window ) {
					list( $start_min, $end_min ) = $window;
					for ( $m = $start_min; $m + $duration <= $end_min; $m += $duration ) {
						$slot_start = $this->site_to_utc( $date_str, $m, $site_tz, $utc );
						if ( null === $slot_start ) {
							// Nicht existierende Wanduhrzeit (Fruehjahrs-Luecke).
							continue;
						}
						$slot_end = $slot_start->add( new \DateInterval( 'PT' . $duration . 'M' ) );

						if ( $slot_start < $earliest_start || $slot_start >= $horizon_cutoff || $slot_start < $window_from || $slot_start >= $window_to ) {
							continue;
						}
						if ( $this->overlaps_any( $slot_start, $slot_end, $day_blocks ) ) {
							continue;
						}
						// Kandidat um eigene Puffer weiten, dann gegen Buchungen pruefen.
						$cand_start = $slot_start->sub( new \DateInterval( 'PT' . $buf_before_new . 'M' ) );
						$cand_end   = $slot_end->add( new \DateInterval( 'PT' . $buf_after_new . 'M' ) );
						if ( $this->overlaps_any( $cand_start, $cand_end, $booking_blocks ) ) {
							continue;
						}

						$slots[ $slot_start->format( 'Y-m-d H:i:s' ) ] = $slot_start;
					}
				}
			}

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

		ksort( $slots );
		return array_values( $slots );
	}

	/**
	 * Ausnahme-Sperrintervalle (UTC) fuer einen Site-Kalendertag.
	 *
	 * @param string        $date_str   'Y-m-d' (Site).
	 * @param array<int, array<string, mixed>> $exceptions Ausnahmen.
	 * @param \DateTimeZone $site_tz    Site-Zeitzone.
	 * @param \DateTimeZone $utc        UTC.
	 * @return array<int, array{0:\DateTimeImmutable,1:\DateTimeImmutable}>
	 */
	private function exception_blocks_for_day( string $date_str, array $exceptions, \DateTimeZone $site_tz, \DateTimeZone $utc ): array {
		$blocks = array();
		foreach ( $exceptions as $ex ) {
			$from = (string) ( $ex['date_from'] ?? '' );
			$to   = (string) ( $ex['date_to'] ?? $from );
			if ( '' === $from ) {
				continue;
			}
			if ( '' === $to ) {
				$to = $from;
			}
			if ( $date_str < $from || $date_str > $to ) {
				continue;
			}

			$start_time = (string) ( $ex['start_time'] ?? '' );
			$end_time   = (string) ( $ex['end_time'] ?? '' );
			$start_min  = '' !== $start_time ? $this->time_to_minutes( $start_time ) : null;
			$end_min    = '' !== $end_time ? $this->time_to_minutes( $end_time ) : null;

			if ( null === $start_min || null === $end_min || $end_min <= $start_min ) {
				// Ganztaegig: kompletter Kalendertag (Site) gesperrt.
				$day_start = $this->site_to_utc( $date_str, 0, $site_tz, $utc );
				if ( null === $day_start ) {
					$day_start = ( new \DateTimeImmutable( $date_str . ' 00:00:00', $site_tz ) )->setTimezone( $utc );
				}
				$next = ( new \DateTimeImmutable( $date_str . ' 00:00:00', $site_tz ) )->add( new \DateInterval( 'P1D' ) )->setTimezone( $utc );
				$blocks[] = array( $day_start, $next );
				continue;
			}

			// Teiltaegig: nur dieses Fenster an diesem Tag.
			$b_start = $this->site_to_utc( $date_str, $start_min, $site_tz, $utc );
			$b_end   = $this->site_to_utc( $date_str, $end_min, $site_tz, $utc );
			if ( null !== $b_start && null !== $b_end ) {
				$blocks[] = array( $b_start, $b_end );
			}
		}
		return $blocks;
	}

	/**
	 * Prueft, ob [start, end) eines der Intervalle ueberlappt.
	 *
	 * @param \DateTimeImmutable $start   Kandidat-Start.
	 * @param \DateTimeImmutable $end     Kandidat-Ende.
	 * @param array<int, array{0:\DateTimeImmutable,1:\DateTimeImmutable}> $intervals Sperrintervalle.
	 * @return bool
	 */
	private function overlaps_any( \DateTimeImmutable $start, \DateTimeImmutable $end, array $intervals ): bool {
		foreach ( $intervals as $iv ) {
			if ( $start < $iv[1] && $iv[0] < $end ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Wandelt eine Site-Wanduhrzeit (Datum + Minuten seit Mitternacht) nach UTC.
	 * Liefert null, wenn die Wanduhrzeit nicht existiert (Fruehjahrs-Luecke).
	 *
	 * @param string        $date_str 'Y-m-d' (Site).
	 * @param int           $minutes  Minuten seit Mitternacht (Site).
	 * @param \DateTimeZone $site_tz  Site-Zeitzone.
	 * @param \DateTimeZone $utc      UTC.
	 * @return \DateTimeImmutable|null
	 */
	private function site_to_utc( string $date_str, int $minutes, \DateTimeZone $site_tz, \DateTimeZone $utc ): ?\DateTimeImmutable {
		$h    = intdiv( $minutes, 60 );
		$mm   = $minutes % 60;
		$wall = sprintf( '%s %02d:%02d', $date_str, $h, $mm );

		$dt = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $wall, $site_tz );
		if ( false === $dt ) {
			return null;
		}
		// Existenzpruefung: hat PHP die Wanduhrzeit verschoben (Luecke), weicht die
		// zurueckformatierte Wanduhrzeit von der gewuenschten ab.
		if ( $dt->format( 'Y-m-d H:i' ) !== $wall ) {
			return null;
		}
		return $dt->setTimezone( $utc );
	}

	/**
	 * Wandelt einen UTC-String oder DateTimeInterface in ein UTC-DateTimeImmutable.
	 *
	 * @param mixed         $value Zeit.
	 * @param \DateTimeZone $utc   UTC.
	 * @return \DateTimeImmutable|null
	 */
	private function to_utc( $value, \DateTimeZone $utc ): ?\DateTimeImmutable {
		if ( $value instanceof \DateTimeInterface ) {
			return \DateTimeImmutable::createFromInterface( $value )->setTimezone( $utc );
		}
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return null;
		}
		try {
			return new \DateTimeImmutable( $value, $utc );
		} catch ( \Exception $e ) {
			return null;
		}
	}

	/**
	 * Zeit ('H:i' oder 'H:i:s') in Minuten seit Mitternacht. Ungueltig => null.
	 *
	 * @param string $time Zeit.
	 * @return int|null
	 */
	private function time_to_minutes( string $time ): ?int {
		$time = trim( $time );
		if ( ! preg_match( '/^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/', $time, $m ) ) {
			return null;
		}
		return ( (int) $m[1] ) * 60 + (int) $m[2];
	}
}
