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

namespace Hafenstudios\Kurato\Site;

use Hafenstudios\Kurato\Booking\BookingMailer;
use Hafenstudios\Kurato\Booking\BookingService;
use Hafenstudios\Kurato\Booking\BookingToken;
use Hafenstudios\Kurato\Booking\BookingWeek;
use Hafenstudios\Kurato\Booking\SlotCalculator;
use Hafenstudios\Kurato\Db\AvailabilityRepository;
use Hafenstudios\Kurato\Db\BookingRepository;
use Hafenstudios\Kurato\Db\BookingTypeRepository;
use Hafenstudios\Kurato\Db\HostRepository;

defined( 'ABSPATH' ) || exit;

/**
 * Oeffentliche Buchungsseite und Terminverwaltung (K5c, K5-KONZEPT Abschnitt 4-5).
 *
 * Eine eigenstaendige, escapte HTML-Seite (kein Theme, Muster PreferenceCenter),
 * erreichbar unter `/termin/{host-slug}` (Rewrite + Query-Var + template_redirect).
 * Drei serverseitige Schritte (POST/Redirect/GET): Terminart + Slot waehlen,
 * Daten eingeben (mit Pflicht-Checkbox, Honeypot, Zeitfalle, Rate-Limit) und
 * Bestaetigung. Zusaetzlich zwei token-gesicherte Endpunkte am Seitenkopf:
 * die Terminverwaltung/Storno (`kurato_booking`) und der ICS-Download
 * (`kurato_ics`). Die Buchung selbst erledigt der HTTP-freie {@see BookingService}.
 *
 * Anders als das Preference Center traegt diese Seite KEIN noindex: die
 * Buchungsseite darf gefunden werden (Inhaber-Seite). Die Verwaltungs- und
 * ICS-Endpunkte sind token-gebunden und werden nicht indexiert.
 *
 * Nicht final, damit Tests die Kernlogik (Storno, ICS) ohne HTTP pruefen koennen.
 */
class BookingPage {

	public const QUERY_HOST   = 'kurato_termin';
	public const QUERY_MANAGE = 'kurato_booking';
	public const QUERY_ICS    = 'kurato_ics';

	/**
	 * Schwelle des IP-Rate-Limits (Anfragen pro Zeitfenster).
	 */
	private const RATE_LIMIT = 20;

	private HostRepository $hosts;

	private BookingTypeRepository $types;

	private AvailabilityRepository $availability;

	private BookingRepository $bookings;

	private SlotCalculator $slots;

	private BookingService $service;

	private BookingMailer $mailer;

	private BookingToken $tokens;

	/**
	 * @param HostRepository         $hosts        Host-Repository.
	 * @param BookingTypeRepository  $types        Terminarten-Repository.
	 * @param AvailabilityRepository $availability Verfuegbarkeits-Repository.
	 * @param BookingRepository      $bookings     Buchungs-Repository.
	 * @param SlotCalculator         $slots        Slot-Rechner.
	 * @param BookingService         $service      Buchungs-Kernlogik.
	 * @param BookingMailer          $mailer       Transaktionsmailer (ICS-Inhalt).
	 * @param BookingToken           $tokens       Token-Dienst.
	 */
	public function __construct(
		HostRepository $hosts,
		BookingTypeRepository $types,
		AvailabilityRepository $availability,
		BookingRepository $bookings,
		SlotCalculator $slots,
		BookingService $service,
		BookingMailer $mailer,
		BookingToken $tokens
	) {
		$this->hosts        = $hosts;
		$this->types        = $types;
		$this->availability = $availability;
		$this->bookings     = $bookings;
		$this->slots        = $slots;
		$this->service      = $service;
		$this->mailer       = $mailer;
		$this->tokens       = $tokens;
	}

	/**
	 * Verdrahtet Rewrite, Query-Vars und den Seiten-Handler.
	 *
	 * @return void
	 */
	public function register(): void {
		add_action( 'init', array( __CLASS__, 'add_rewrite_rules' ) );
		add_filter(
			'query_vars',
			static function ( $vars ) {
				$vars[] = self::QUERY_HOST;
				$vars[] = self::QUERY_MANAGE;
				$vars[] = self::QUERY_ICS;
				return $vars;
			}
		);
		add_action( 'template_redirect', array( $this, 'maybe_render' ) );
	}

	/**
	 * Registriert die Rewrite-Regel `/termin/{slug}`. Auch aus dem Installer
	 * aufgerufen, damit das anschliessende flush_rewrite_rules() die Regel kennt.
	 * Der Namespace ist bewusst fest (Inhaber-Entscheidung K5); der Filter
	 * `kurato_booking_base` erlaubt Entwicklern eine andere Basis.
	 *
	 * @return void
	 */
	public static function add_rewrite_rules(): void {
		$base = self::base_segment();
		add_rewrite_rule( '^' . $base . '/([^/]+)/?$', 'index.php?' . self::QUERY_HOST . '=$matches[1]', 'top' );
	}

	/**
	 * Basis-Segment der oeffentlichen URL (Standard "termin").
	 *
	 * @return string
	 */
	private static function base_segment(): string {
		/**
		 * Basis-Segment der oeffentlichen Buchungs-URL.
		 *
		 * @param string $base Standard "termin".
		 */
		$base = (string) apply_filters( 'kurato_booking_base', 'termin' );
		$base = trim( $base, '/' );
		return '' !== $base ? $base : 'termin';
	}

	// --- Statische URL-Helfer ---------------------------------------------

	/**
	 * Oeffentliche Buchungs-URL eines Hosts.
	 *
	 * @param string $slug Host-Slug.
	 * @return string
	 */
	public static function booking_url( string $slug ): string {
		return home_url( '/' . self::base_segment() . '/' . $slug );
	}

	/**
	 * Terminverwaltungs-/Storno-URL fuer einen Booking-Token.
	 *
	 * @param string $token Booking-Token.
	 * @return string
	 */
	public static function manage_url( string $token ): string {
		return add_query_arg( array( self::QUERY_MANAGE => 1, 'token' => $token ), home_url( '/' ) );
	}

	/**
	 * ICS-Download-URL fuer einen Booking-Token.
	 *
	 * @param string $token Booking-Token.
	 * @return string
	 */
	public static function ics_url( string $token ): string {
		return add_query_arg( array( self::QUERY_ICS => 1, 'token' => $token ), home_url( '/' ) );
	}

	// --- Router ------------------------------------------------------------

	/**
	 * template_redirect: erkennt ICS-Download, Terminverwaltung oder Buchungsseite.
	 *
	 * @return void
	 */
	public function maybe_render(): void {
		if ( '' !== (string) get_query_var( self::QUERY_ICS ) ) {
			$this->serve_ics();
			exit;
		}
		if ( '' !== (string) get_query_var( self::QUERY_MANAGE ) ) {
			$this->route_manage();
			exit;
		}
		$slug = (string) get_query_var( self::QUERY_HOST );
		if ( '' === $slug ) {
			return;
		}
		$this->route_booking( $slug );
		exit;
	}

	// --- ICS-Endpoint ------------------------------------------------------

	/**
	 * Liefert den ICS-Inhalt fuer einen gueltigen Booking-Token oder null, wenn der
	 * Token ungueltig/zweckfremd ist oder die Buchung fehlt. Rein testbar (kein
	 * Header-/Exit-Nebeneffekt).
	 *
	 * @param string $token Booking-Token.
	 * @return string|null ICS-Text oder null.
	 */
	public function ics_payload( string $token ): ?string {
		$claim = $this->tokens->verify( $token );
		if ( ! is_int( $claim ) ) {
			return null; // Ungueltig oder zweckfremd (BookingToken-Zweckbindung).
		}
		$booking = $this->bookings->get( $claim );
		if ( null === $booking ) {
			return null;
		}
		$host = $this->hosts->get( (int) $booking['host_id'] );
		$type = $this->types->get( (int) $booking['type_id'] );
		if ( null === $host || null === $type ) {
			return null;
		}
		return $this->mailer->ics_for_booking( $booking, $host, $type );
	}

	/**
	 * ICS-Download ausliefern (Content-Type text/calendar, als Anhang).
	 *
	 * @return void
	 */
	private function serve_ics(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Token-gesichert (kein Login, keine Nonce).
		$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
		$ics   = $this->ics_payload( $token );
		if ( null === $ics ) {
			status_header( 404 );
			$this->render_message_page( __( 'Termin nicht gefunden', 'kurato' ), __( 'Zu diesem Link liegt kein Termin vor.', 'kurato' ) );
			return;
		}
		if ( ! headers_sent() ) {
			nocache_headers();
			header( 'Content-Type: text/calendar; charset=utf-8' );
			header( 'Content-Disposition: attachment; filename="termin.ics"' );
		}
		echo $ics; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ICS-Text, RFC-5545-escaped im Builder.
	}

	// --- Terminverwaltung / Storno ----------------------------------------

	/**
	 * GET/POST-Router der Terminverwaltung.
	 *
	 * @return void
	 */
	private function route_manage(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Token-gesichert.
		$token  = isset( $_REQUEST['token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['token'] ) ) : '';
		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';

		if ( 'POST' === $method ) {
			if ( $this->rate_limited( 'manage' ) ) {
				$this->render_message_page( __( 'Zu viele Anfragen', 'kurato' ), __( 'Bitte versuchen Sie es in einigen Minuten erneut.', 'kurato' ) );
				return;
			}
			// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token-gesichert.
			$result = $this->handle_cancel( $token, wp_unslash( $_POST ) );
			wp_safe_redirect( add_query_arg( array( self::QUERY_MANAGE => 1, 'token' => $token, 'msg' => $result ), home_url( '/' ) ) );
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nur Anzeige nach Redirect.
		$msg = isset( $_GET['msg'] ) ? sanitize_key( wp_unslash( $_GET['msg'] ) ) : '';
		$this->render_manage( $token, $msg );
	}

	/**
	 * Storno-Kernlogik (rein, ohne Rendering/Redirect, damit testbar). Prueft den
	 * zweckgebundenen Token; storniert nur eine confirmed-Buchung, deren Start in
	 * der Zukunft liegt, und feuert danach `kurato_booking_cancelled` mit source
	 * 'visitor'. Ergebnis-Codes: `cancelled`, `invalid`, `too_late`, `already`,
	 * `noop`.
	 *
	 * @param string               $token Booking-Token.
	 * @param array<string, mixed> $post  POST-Daten (bereits unslashed).
	 * @return string
	 */
	public function handle_cancel( string $token, array $post ): string {
		$op = isset( $post['op'] ) ? sanitize_key( (string) $post['op'] ) : '';
		if ( 'cancel' !== $op ) {
			return 'noop';
		}

		$claim = $this->tokens->verify( $token );
		if ( ! is_int( $claim ) ) {
			return 'invalid';
		}
		$booking = $this->bookings->get( $claim );
		if ( null === $booking ) {
			return 'invalid';
		}
		if ( 'confirmed' !== (string) $booking['status'] ) {
			return 'already'; // Schon storniert -> freundlich wirkungslos.
		}
		if ( $this->is_past( (string) $booking['start_utc'] ) ) {
			return 'too_late'; // Termin vorbei -> wirkungslos.
		}

		if ( ! $this->bookings->cancel( $claim ) ) {
			return 'already';
		}

		/**
		 * Eine Buchung wurde ueber den Besucher-Link storniert.
		 *
		 * @param int    $booking_id Buchungs-ID.
		 * @param string $source     'visitor'.
		 */
		do_action( 'kurato_booking_cancelled', $claim, 'visitor' );
		return 'cancelled';
	}

	/**
	 * Rendert die Terminverwaltung: Terminuebersicht, Storno-Button (POST), ICS
	 * und ein Link "Neuen Termin buchen" (Umbuchung v1). Bei ungueltigem Token eine
	 * freundliche Fehlerseite.
	 *
	 * @param string $token Booking-Token.
	 * @param string $msg   Statuscode aus dem PRG-Redirect.
	 * @return void
	 */
	private function render_manage( string $token, string $msg ): void {
		$claim = $this->tokens->verify( $token );
		if ( ! is_int( $claim ) ) {
			$this->render_message_page( __( 'Link ungueltig', 'kurato' ), __( 'Dieser Link ist ungueltig. Bitte nutzen Sie den Link aus Ihrer Terminbestaetigung.', 'kurato' ) );
			return;
		}
		$booking = $this->bookings->get( $claim );
		if ( null === $booking ) {
			$this->render_message_page( __( 'Termin nicht gefunden', 'kurato' ), __( 'Zu diesem Link liegt kein Termin vor.', 'kurato' ) );
			return;
		}
		$host = $this->hosts->get( (int) $booking['host_id'] );
		$type = $this->types->get( (int) $booking['type_id'] );
		if ( null === $host || null === $type ) {
			$this->render_message_page( __( 'Termin nicht gefunden', 'kurato' ), __( 'Zu diesem Link liegt kein Termin vor.', 'kurato' ) );
			return;
		}

		$this->render_head( __( 'Ihr Termin', 'kurato' ) );
		echo '<div class="hs-card">';

		if ( 'booked' === $msg ) {
			echo '<div class="hs-notice hs-notice--ok">' . esc_html__( 'Vielen Dank. Ihr Termin ist gebucht. Sie erhalten die Bestaetigung auch per E-Mail.', 'kurato' ) . '</div>';
		} elseif ( 'cancelled' === $msg ) {
			echo '<div class="hs-notice hs-notice--ok">' . esc_html__( 'Ihr Termin wurde abgesagt.', 'kurato' ) . '</div>';
		} elseif ( 'too_late' === $msg ) {
			echo '<div class="hs-notice hs-notice--warn">' . esc_html__( 'Dieser Termin liegt bereits in der Vergangenheit und kann nicht mehr abgesagt werden.', 'kurato' ) . '</div>';
		} elseif ( 'already' === $msg ) {
			echo '<div class="hs-notice hs-notice--warn">' . esc_html__( 'Dieser Termin ist bereits abgesagt.', 'kurato' ) . '</div>';
		} elseif ( 'invalid' === $msg ) {
			echo '<div class="hs-notice hs-notice--warn">' . esc_html__( 'Die Aktion war nicht moeglich.', 'kurato' ) . '</div>';
		}

		$cancelled = 'cancelled' === (string) $booking['status'];
		$past      = $this->is_past( (string) $booking['start_utc'] );

		echo '<h1>' . esc_html__( 'Ihr Termin', 'kurato' ) . '</h1>';
		$this->render_booking_summary( $host, $type, (string) $booking['start_utc'] );

		if ( $cancelled ) {
			echo '<p class="hs-hint">' . esc_html__( 'Dieser Termin ist abgesagt.', 'kurato' ) . '</p>';
			echo '<p><a class="hs-button" href="' . esc_url( self::booking_url( (string) $host['slug'] ) ) . '">' . esc_html__( 'Neuen Termin buchen', 'kurato' ) . '</a></p>';
		} else {
			echo '<p><a class="hs-button hs-button--link" href="' . esc_url( self::ics_url( $token ) ) . '">' . esc_html__( 'Kalenderdatei herunterladen', 'kurato' ) . '</a></p>';
			if ( ! $past ) {
				echo '<form method="post" action="' . esc_url( home_url( '/' ) ) . '" onsubmit="return confirm(\'' . esc_js( __( 'Diesen Termin wirklich absagen?', 'kurato' ) ) . '\');">';
				echo '<input type="hidden" name="' . esc_attr( self::QUERY_MANAGE ) . '" value="1" />';
				echo '<input type="hidden" name="token" value="' . esc_attr( $token ) . '" />';
				echo '<input type="hidden" name="op" value="cancel" />';
				echo '<button type="submit" class="hs-button hs-button--danger">' . esc_html__( 'Termin absagen', 'kurato' ) . '</button>';
				echo '</form>';
				echo '<p class="hs-hint">' . esc_html__( 'Zum Verschieben sagen Sie den Termin ab und buchen einen neuen.', 'kurato' ) . '</p>';
			} else {
				echo '<p class="hs-hint">' . esc_html__( 'Dieser Termin liegt in der Vergangenheit.', 'kurato' ) . '</p>';
			}
			echo '<p><a href="' . esc_url( self::booking_url( (string) $host['slug'] ) ) . '">' . esc_html__( 'Neuen Termin buchen', 'kurato' ) . '</a></p>';
		}

		echo '</div>';
		$this->render_foot();
	}

	// --- Buchungsseite -----------------------------------------------------

	/**
	 * Router der Buchungsseite (Schritt-Steuerung ueber GET/POST).
	 *
	 * @param string $slug Host-Slug.
	 * @return void
	 */
	private function route_booking( string $slug ): void {
		$host = $this->hosts->get_by_slug( $slug );
		if ( null === $host || empty( $host['active'] ) ) {
			status_header( 404 );
			$this->render_message_page( __( 'Seite nicht gefunden', 'kurato' ), __( 'Diese Buchungsseite ist nicht verfuegbar.', 'kurato' ) );
			return;
		}

		$types = $this->active_types( (int) $host['id'] );
		if ( array() === $types ) {
			$this->render_message_page( __( 'Keine Termine verfuegbar', 'kurato' ), __( 'Aktuell werden hier keine Termine angeboten. Bitte schauen Sie spaeter wieder vorbei.', 'kurato' ) );
			return;
		}

		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
		if ( 'POST' === $method ) {
			$this->handle_booking_post( $host, $types );
			return;
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Oeffentliche Auswahl, keine Wirkung.
		$art  = isset( $_GET['art'] ) ? sanitize_title( wp_unslash( $_GET['art'] ) ) : '';
		$wk   = isset( $_GET['wk'] ) ? (int) wp_unslash( $_GET['wk'] ) : 0;
		$slot = isset( $_GET['slot'] ) ? sanitize_text_field( wp_unslash( $_GET['slot'] ) ) : '';
		$step = isset( $_GET['step'] ) ? sanitize_key( wp_unslash( $_GET['step'] ) ) : '';
		// phpcs:enable

		$type = '' !== $art ? $this->find_type( $types, $art ) : ( 1 === count( $types ) ? $types[0] : null );

		if ( null === $type ) {
			$this->render_type_chooser( $host, $types );
			return;
		}

		if ( 'form' === $step && '' !== $slot && $this->valid_utc( $slot ) ) {
			$this->render_details_form( $host, $type, $slot, '' );
			return;
		}

		$this->render_week( $host, $type, $wk );
	}

	/**
	 * Verarbeitet den Buchungs-POST (Schritt 2 -> 3).
	 *
	 * @param array<string, mixed>              $host  Host.
	 * @param array<int, array<string, mixed>>  $types Aktive Terminarten.
	 * @return void
	 */
	private function handle_booking_post( array $host, array $types ): void {
		if ( $this->rate_limited( 'book' ) ) {
			$this->render_message_page( __( 'Zu viele Anfragen', 'kurato' ), __( 'Bitte versuchen Sie es in einigen Minuten erneut.', 'kurato' ) );
			return;
		}

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Oeffentlich, Schutz ueber Honeypot/Zeitfalle/Rate-Limit (K5-KONZEPT 4).
		$post = wp_unslash( $_POST );
		$art  = isset( $post['art'] ) ? sanitize_title( (string) $post['art'] ) : '';
		$slot = isset( $post['slot'] ) ? sanitize_text_field( (string) $post['slot'] ) : '';
		// phpcs:enable

		$type = $this->find_type( $types, $art );
		if ( null === $type || ! $this->valid_utc( $slot ) ) {
			$this->render_message_page( __( 'Etwas ist schiefgelaufen', 'kurato' ), __( 'Bitte waehlen Sie den Termin erneut.', 'kurato' ) );
			return;
		}

		$input = array(
			'name'    => (string) ( $post['name'] ?? '' ),
			'email'   => (string) ( $post['email'] ?? '' ),
			'phone'   => (string) ( $post['phone'] ?? '' ),
			'note'    => (string) ( $post['note'] ?? '' ),
			'consent' => ! empty( $post['consent'] ),
			'hp'      => (string) ( $post['hs_website'] ?? '' ),
			'started' => (int) ( $post['form_started'] ?? 0 ),
			'slot'    => $slot,
		);

		$result = $this->service->book( $input, $host, $type );
		if ( ! empty( $result['ok'] ) ) {
			// PRG: auf die Terminverwaltung (= Bestaetigungsseite mit ICS + Storno).
			$token = $this->tokens->create( (int) $result['booking_id'] );
			wp_safe_redirect( add_query_arg( array( self::QUERY_MANAGE => 1, 'token' => $token, 'msg' => 'booked' ), home_url( '/' ) ) );
			return;
		}

		// Fehler: Formular erneut mit Meldung anzeigen (Slot erhalten).
		$this->render_details_form( $host, $type, $slot, (string) $result['error'], $input );
	}

	// --- Rendering: Schritte ----------------------------------------------

	/**
	 * Schritt 1a: Terminart waehlen (nur bei mehreren aktiven Arten).
	 *
	 * @param array<string, mixed>             $host  Host.
	 * @param array<int, array<string, mixed>> $types Aktive Terminarten.
	 * @return void
	 */
	private function render_type_chooser( array $host, array $types ): void {
		$this->render_head( $this->page_title( $host ) );
		echo '<div class="hs-card">';
		$this->render_host_header( $host );
		echo '<h2>' . esc_html__( 'Terminart waehlen', 'kurato' ) . '</h2>';
		echo '<ul class="hs-types">';
		foreach ( $types as $type ) {
			echo '<li class="hs-type-row">';
			echo '<a class="hs-type-link" href="' . esc_url( self::booking_url( (string) $host['slug'] ) . '?art=' . rawurlencode( (string) $type['slug'] ) ) . '">';
			echo '<span class="hs-type-label">' . esc_html( (string) $type['label'] ) . '</span> ';
			echo '<span class="hs-type-meta">' . esc_html( sprintf( /* translators: %d: Minuten */ __( '%d Min.', 'kurato' ), (int) $type['duration_min'] ) ) . '</span>';
			echo '</a>';
			echo '</li>';
		}
		echo '</ul>';
		echo '</div>';
		$this->render_foot();
	}

	/**
	 * Schritt 1b: Wochenansicht der buchbaren Slots.
	 *
	 * @param array<string, mixed> $host Host.
	 * @param array<string, mixed> $type Terminart.
	 * @param int                  $wk   Wochen-Offset.
	 * @return void
	 */
	private function render_week( array $host, array $type, int $wk ): void {
		$wk      = max( 0, min( 520, $wk ) ); // nur Gegenwart/Zukunft, geklemmt.
		$site_tz = wp_timezone();
		$week    = BookingWeek::range( new \DateTimeImmutable( 'now', $site_tz ), $wk );
		$utc     = new \DateTimeZone( 'UTC' );
		$now     = new \DateTimeImmutable( 'now', $utc );

		$from_utc = BookingWeek::to_utc_string( $week['start'] );
		$to_utc   = BookingWeek::to_utc_string( $week['end'] );

		$slots = $this->slots->available_slots(
			$type,
			$this->availability->weekly_for_type( (int) $host['id'], (int) $type['id'] ),
			$this->availability->exceptions_for_host( (int) $host['id'] ),
			$this->bookings->bookings_for_host_between( (int) $host['id'], $from_utc, $to_utc ),
			$week['start'],
			$week['end'],
			$now,
			$site_tz
		);

		// Slots nach Site-Kalendertag gruppieren.
		$by_day = array();
		foreach ( $slots as $slot ) {
			$local              = $slot->setTimezone( $site_tz );
			$by_day[ $local->format( 'Y-m-d' ) ][] = $slot;
		}

		$this->render_head( $this->page_title( $host ) );
		echo '<div class="hs-card">';
		$this->render_host_header( $host );

		$base = self::booking_url( (string) $host['slug'] );
		echo '<h2>' . esc_html( (string) $type['label'] ) . '</h2>';

		// Zeitzonen-Hinweis (Site-Zeitzone; Besucher-Automatik ist Pro).
		echo '<p class="hs-hint">' . esc_html( sprintf( /* translators: %s: Zeitzone */ __( 'Alle Zeiten in %s.', 'kurato' ), $site_tz->getName() ) ) . '</p>';

		// Wochen-Navigation.
		$end_display = $week['end']->modify( '-1 day' );
		echo '<div class="hs-week-nav">';
		if ( $wk > 0 ) {
			echo '<a class="hs-button hs-button--small" href="' . esc_url( $base . '?art=' . rawurlencode( (string) $type['slug'] ) . '&wk=' . ( $wk - 1 ) ) . '">&laquo; ' . esc_html__( 'Vorige Woche', 'kurato' ) . '</a>';
		} else {
			echo '<span></span>';
		}
		echo '<strong>' . esc_html( $this->format_day( $week['start'] ) . ' - ' . $this->format_day( $end_display ) ) . '</strong>';
		echo '<a class="hs-button hs-button--small" href="' . esc_url( $base . '?art=' . rawurlencode( (string) $type['slug'] ) . '&wk=' . ( $wk + 1 ) ) . '">' . esc_html__( 'Naechste Woche', 'kurato' ) . ' &raquo;</a>';
		echo '</div>';

		if ( array() === $by_day ) {
			echo '<p>' . esc_html__( 'In dieser Woche sind keine Termine frei. Bitte waehlen Sie eine andere Woche.', 'kurato' ) . '</p>';
		} else {
			ksort( $by_day );
			echo '<div class="hs-days">';
			foreach ( $by_day as $date => $day_slots ) {
				$day_dt = new \DateTimeImmutable( $date . ' 00:00:00', $site_tz );
				echo '<div class="hs-day">';
				echo '<h3>' . esc_html( $this->format_weekday( $day_dt ) ) . '</h3>';
				echo '<div class="hs-slots">';
				foreach ( $day_slots as $slot ) {
					$utc_str = $slot->setTimezone( $utc )->format( 'Y-m-d H:i:s' );
					$label   = $slot->setTimezone( $site_tz )->format( 'H:i' );
					$url     = $base . '?art=' . rawurlencode( (string) $type['slug'] ) . '&step=form&slot=' . rawurlencode( $utc_str );
					echo '<a class="hs-slot" href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a>';
				}
				echo '</div></div>';
			}
			echo '</div>';
		}

		if ( count( $this->active_types( (int) $host['id'] ) ) > 1 ) {
			echo '<p><a href="' . esc_url( $base ) . '">&laquo; ' . esc_html__( 'Andere Terminart', 'kurato' ) . '</a></p>';
		}

		echo '</div>';
		$this->render_foot();
	}

	/**
	 * Schritt 2: Daten-Formular.
	 *
	 * @param array<string, mixed> $host   Host.
	 * @param array<string, mixed> $type   Terminart.
	 * @param string               $slot   Slot 'Y-m-d H:i:s' (UTC).
	 * @param string               $error  Fehlercode (leer = kein Fehler).
	 * @param array<string, mixed> $values Vorbelegung nach Fehler.
	 * @return void
	 */
	private function render_details_form( array $host, array $type, string $slot, string $error, array $values = array() ): void {
		$base = self::booking_url( (string) $host['slug'] );

		$this->render_head( $this->page_title( $host ) );
		echo '<div class="hs-card">';
		$this->render_host_header( $host );

		echo '<h2>' . esc_html__( 'Ihre Angaben', 'kurato' ) . '</h2>';
		$this->render_booking_summary( $host, $type, $slot );

		$msg = $this->error_message( $error );
		if ( '' !== $msg ) {
			echo '<div class="hs-notice hs-notice--warn">' . esc_html( $msg ) . '</div>';
		}

		echo '<form method="post" action="' . esc_url( $base ) . '">';
		// WICHTIG: Als Wert MUSS der Host-Slug stehen. WordPress laesst POST-Werte
		// oeffentlicher Query-Vars die Rewrite-Werte ueberschreiben - ein "1" wuerde
		// beim Absenden den Slug aus der URL verdraengen (Host "1" -> 404).
		echo '<input type="hidden" name="' . esc_attr( self::QUERY_HOST ) . '" value="' . esc_attr( (string) $host['slug'] ) . '" />';
		echo '<input type="hidden" name="art" value="' . esc_attr( (string) $type['slug'] ) . '" />';
		echo '<input type="hidden" name="slot" value="' . esc_attr( $slot ) . '" />';
		echo '<input type="hidden" name="form_started" value="' . esc_attr( (string) time() ) . '" />';
		// Honeypot: fuer Menschen ausgeblendet, von Bots gern ausgefuellt.
		echo '<div style="position:absolute;left:-9999px;" aria-hidden="true"><label>' . esc_html__( 'Ihre Webseite', 'kurato' ) . '<input type="text" name="hs_website" tabindex="-1" autocomplete="off" value="" /></label></div>';

		echo '<p><label>' . esc_html__( 'Name', 'kurato' ) . ' *<br /><input type="text" name="name" required value="' . esc_attr( (string) ( $values['name'] ?? '' ) ) . '" /></label></p>';
		echo '<p><label>' . esc_html__( 'E-Mail', 'kurato' ) . ' *<br /><input type="email" name="email" required value="' . esc_attr( (string) ( $values['email'] ?? '' ) ) . '" /></label></p>';
		echo '<p><label>' . esc_html__( 'Telefon (optional)', 'kurato' ) . '<br /><input type="tel" name="phone" value="' . esc_attr( (string) ( $values['phone'] ?? '' ) ) . '" /></label></p>';
		echo '<p><label>' . esc_html__( 'Ihr Anliegen (optional)', 'kurato' ) . '<br /><textarea name="note" rows="3">' . esc_textarea( (string) ( $values['note'] ?? '' ) ) . '</textarea></label></p>';

		echo '<p class="hs-check"><label><input type="checkbox" name="consent" value="1" required /> ' . $this->consent_label() . '</label></p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- intern escaped.

		echo '<p><button type="submit" class="hs-button">' . esc_html__( 'Termin verbindlich buchen', 'kurato' ) . '</button></p>';
		echo '</form>';

		echo '<p><a href="' . esc_url( $base . '?art=' . rawurlencode( (string) $type['slug'] ) ) . '">&laquo; ' . esc_html__( 'Anderen Zeitpunkt waehlen', 'kurato' ) . '</a></p>';

		echo '</div>';
		$this->render_foot();
	}

	// --- Rendering-Helfer --------------------------------------------------

	/**
	 * Kopf mit Foto, Name und Rolle des Hosts.
	 *
	 * @param array<string, mixed> $host Host.
	 * @return void
	 */
	private function render_host_header( array $host ): void {
		echo '<div class="hs-host-head">';
		$photo_id = ! empty( $host['photo_id'] ) ? (int) $host['photo_id'] : 0;
		if ( $photo_id > 0 && function_exists( 'wp_get_attachment_image' ) ) {
			$img = wp_get_attachment_image( $photo_id, array( 72, 72 ), false, array( 'class' => 'hs-host-photo' ) );
			if ( '' !== $img ) {
				echo $img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_get_attachment_image liefert escaptes HTML.
			}
		}
		echo '<div>';
		echo '<h1>' . esc_html( (string) $host['label'] ) . '</h1>';
		if ( '' !== (string) ( $host['role'] ?? '' ) ) {
			echo '<p class="hs-host-role">' . esc_html( (string) $host['role'] ) . '</p>';
		}
		echo '</div>';
		echo '</div>';
	}

	/**
	 * Termin-Uebersicht (Terminart, Ansprechpartner, Zeit, Ort).
	 *
	 * @param array<string, mixed> $host      Host.
	 * @param array<string, mixed> $type      Terminart.
	 * @param string               $start_utc Startzeit UTC.
	 * @return void
	 */
	private function render_booking_summary( array $host, array $type, string $start_utc ): void {
		echo '<div class="hs-summary">';
		echo '<strong>' . esc_html( (string) $type['label'] ) . '</strong><br />';
		echo esc_html( sprintf( /* translators: %s: Name */ __( 'mit %s', 'kurato' ), (string) $host['label'] ) ) . '<br />';
		echo esc_html( $this->format_datetime( $start_utc ) );
		$where = $this->location_line( $type );
		if ( '' !== $where ) {
			echo '<br />' . esc_html( $where );
		}
		echo '</div>';
	}

	/**
	 * Datenschutz-Checkbox-Text mit Link auf die Datenschutzseite (WP-Privacy-Policy
	 * oder Setting).
	 *
	 * @return string
	 */
	private function consent_label(): string {
		$url = $this->privacy_url();
		$text = esc_html__( 'Ich habe den Datenschutzhinweis gelesen und willige in die Verarbeitung meiner Daten zur Terminabwicklung ein.', 'kurato' );
		if ( '' !== $url ) {
			$text .= ' <a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">' . esc_html__( 'Datenschutzhinweis', 'kurato' ) . '</a>';
		}
		return $text;
	}

	/**
	 * URL der Datenschutzseite.
	 *
	 * @return string
	 */
	private function privacy_url(): string {
		if ( function_exists( 'get_privacy_policy_url' ) ) {
			$url = (string) get_privacy_policy_url();
			if ( '' !== $url ) {
				return $url;
			}
		}
		return '';
	}

	/**
	 * Ort-Zeile aus einer Terminart.
	 *
	 * @param array<string, mixed> $type Terminart.
	 * @return string
	 */
	private function location_line( array $type ): string {
		$labels = array(
			'phone'     => __( 'Telefon', 'kurato' ),
			'video'     => __( 'Video', 'kurato' ),
			'in_person' => __( 'Vor Ort', 'kurato' ),
		);
		$label  = $labels[ (string) ( $type['location_type'] ?? '' ) ] ?? '';
		$detail = trim( (string) ( $type['location_detail'] ?? '' ) );
		if ( '' === $label ) {
			return $detail;
		}
		return '' !== $detail ? $label . ': ' . $detail : $label;
	}

	/**
	 * Menschlicher Fehlertext zu einem Buchungs-Fehlercode.
	 *
	 * @param string $error Fehlercode aus BookingService::book().
	 * @return string
	 */
	private function error_message( string $error ): string {
		switch ( $error ) {
			case 'consent':
				return __( 'Bitte bestaetigen Sie den Datenschutzhinweis.', 'kurato' );
			case 'name':
				return __( 'Bitte geben Sie Ihren Namen an.', 'kurato' );
			case 'email':
				return __( 'Bitte geben Sie eine gueltige E-Mail-Adresse an.', 'kurato' );
			case 'slot_invalid':
				return __( 'Dieser Zeitpunkt ist nicht mehr verfuegbar. Bitte waehlen Sie einen anderen.', 'kurato' );
			case 'slot_taken':
				return __( 'Der Slot ist soeben vergeben worden, bitte waehlen Sie einen anderen.', 'kurato' );
			case 'spam':
				return __( 'Ihre Anfrage konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut.', 'kurato' );
			case '':
				return '';
			default:
				return __( 'Die Buchung war nicht moeglich. Bitte versuchen Sie es erneut.', 'kurato' );
		}
	}

	/**
	 * Aktive Terminarten eines Hosts.
	 *
	 * @param int $host_id Host-ID.
	 * @return array<int, array<string, mixed>>
	 */
	private function active_types( int $host_id ): array {
		return $this->types->for_host( $host_id, true );
	}

	/**
	 * Findet eine Terminart per Slug in einer Liste.
	 *
	 * @param array<int, array<string, mixed>> $types Terminarten.
	 * @param string                           $slug  Slug.
	 * @return array<string, mixed>|null
	 */
	private function find_type( array $types, string $slug ): ?array {
		foreach ( $types as $type ) {
			if ( (string) $type['slug'] === $slug ) {
				return $type;
			}
		}
		return null;
	}

	/**
	 * Seitentitel.
	 *
	 * @param array<string, mixed> $host Host.
	 * @return string
	 */
	private function page_title( array $host ): string {
		return sprintf( /* translators: %s: Name */ __( 'Termin buchen bei %s', 'kurato' ), (string) $host['label'] );
	}

	/**
	 * Liegt eine UTC-Zeit in der Vergangenheit?
	 *
	 * @param string $utc UTC-'Y-m-d H:i:s'.
	 * @return bool
	 */
	private function is_past( string $utc ): bool {
		$ts = strtotime( $utc . ' UTC' );
		return false !== $ts && $ts <= time();
	}

	/**
	 * Ist der String eine UTC-'Y-m-d H:i:s'-Zeit?
	 *
	 * @param string $value Wert.
	 * @return bool
	 */
	private function valid_utc( string $value ): bool {
		return 1 === preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value );
	}

	/**
	 * Formatiert eine UTC-Zeit in der Site-Zeitzone (Datum + Uhrzeit).
	 *
	 * @param string $utc UTC-Zeit.
	 * @return string
	 */
	private function format_datetime( string $utc ): string {
		$ts = strtotime( $utc . ' UTC' );
		if ( false === $ts ) {
			return $utc;
		}
		if ( function_exists( 'wp_date' ) ) {
			return (string) wp_date( 'l, d.m.Y H:i', $ts );
		}
		return gmdate( 'D, d.m.Y H:i', $ts );
	}

	/**
	 * Formatiert einen Wochentag mit Datum.
	 *
	 * @param \DateTimeImmutable $day Tag (Site-Zeit).
	 * @return string
	 */
	private function format_weekday( \DateTimeImmutable $day ): string {
		if ( function_exists( 'wp_date' ) ) {
			return (string) wp_date( 'l, d.m.', $day->getTimestamp() );
		}
		return $day->format( 'D, d.m.' );
	}

	/**
	 * Formatiert ein Datum (ohne Uhrzeit).
	 *
	 * @param \DateTimeImmutable $day Tag.
	 * @return string
	 */
	private function format_day( \DateTimeImmutable $day ): string {
		if ( function_exists( 'wp_date' ) ) {
			return (string) wp_date( 'd.m.', $day->getTimestamp() );
		}
		return $day->format( 'd.m.' );
	}

	// --- Rate-Limit --------------------------------------------------------

	/**
	 * Prueft und erhoeht das IP-Rate-Limit (Muster PreferenceCenter).
	 *
	 * @param string $scope Namensraum (book | manage).
	 * @return bool true, wenn blockiert.
	 */
	private function rate_limited( string $scope ): bool {
		$ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		$key = 'kurato_book_' . $scope . '_' . md5( $ip );

		$count = (int) get_transient( $key );
		if ( $count >= self::RATE_LIMIT ) {
			return true;
		}
		set_transient( $key, $count + 1, 10 * MINUTE_IN_SECONDS );
		return false;
	}

	// --- HTML-Geruest ------------------------------------------------------

	/**
	 * Schlichte Meldungsseite (Fehler/Hinweis).
	 *
	 * @param string $title   Ueberschrift.
	 * @param string $message Text.
	 * @return void
	 */
	private function render_message_page( string $title, string $message ): void {
		$this->render_head( $title );
		echo '<div class="hs-card">';
		echo '<h1>' . esc_html( $title ) . '</h1>';
		echo '<p>' . esc_html( $message ) . '</p>';
		echo '</div>';
		$this->render_foot();
	}

	/**
	 * Kopf der eigenstaendigen HTML-Seite (kein Theme, Inline-CSS). KEIN noindex
	 * auf der Buchungsseite; die token-gebundenen Endpunkte tragen es dennoch.
	 *
	 * @param string $title Seitentitel.
	 * @return void
	 */
	private function render_head( string $title ): void {
		if ( ! headers_sent() ) {
			header( 'Content-Type: text/html; charset=utf-8' );
		}
		$noindex = '' !== (string) get_query_var( self::QUERY_MANAGE ) || '' !== (string) get_query_var( self::QUERY_ICS );
		$site    = get_bloginfo( 'name' );

		echo '<!DOCTYPE html>' . "\n";
		echo '<html lang="de"><head>';
		echo '<meta charset="utf-8" />';
		echo '<meta name="viewport" content="width=device-width, initial-scale=1" />';
		if ( $noindex ) {
			echo '<meta name="robots" content="noindex, nofollow" />';
		}
		echo '<title>' . esc_html( $title ) . '</title>';
		echo '<style>'
			. 'body{margin:0;background:#f4f4f4;color:#222;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;}'
			. '.hs-wrap{max-width:640px;margin:0 auto;padding:32px 16px;}'
			. '.hs-card{background:#fff;border-radius:8px;padding:28px;box-shadow:0 1px 3px rgba(0,0,0,.08);}'
			. 'h1{font-size:22px;margin:0 0 4px;}h2{font-size:17px;margin:24px 0 12px;}h3{font-size:14px;margin:16px 0 8px;color:#444;}'
			. '.hs-hint{color:#777;font-size:13px;margin:6px 0 16px;}'
			. '.hs-host-head{display:flex;gap:14px;align-items:center;margin-bottom:8px;}'
			. '.hs-host-photo{border-radius:50%;width:72px;height:72px;object-fit:cover;}'
			. '.hs-host-role{color:#666;margin:2px 0 0;}'
			. '.hs-types{list-style:none;padding:0;margin:0;}'
			. '.hs-type-row{border-bottom:1px solid #eee;}'
			. '.hs-type-link{display:flex;justify-content:space-between;align-items:center;padding:14px 4px;text-decoration:none;color:#1f6feb;}'
			. '.hs-type-label{font-weight:bold;}.hs-type-meta{color:#777;font-size:13px;}'
			. '.hs-week-nav{display:flex;justify-content:space-between;align-items:center;gap:8px;margin:12px 0;}'
			. '.hs-days{display:flex;flex-direction:column;gap:12px;}'
			. '.hs-slots{display:flex;flex-wrap:wrap;gap:8px;}'
			. '.hs-slot{display:inline-block;padding:8px 12px;border:1px solid #1f6feb;border-radius:6px;color:#1f6feb;text-decoration:none;font-size:15px;}'
			. '.hs-slot:hover{background:#1f6feb;color:#fff;}'
			. '.hs-summary{background:#f4f4f4;border-radius:6px;padding:12px 16px;margin:12px 0;}'
			. 'input[type=text],input[type=email],input[type=tel],textarea{width:100%;max-width:360px;padding:8px;border:1px solid #ccc;border-radius:4px;font-size:15px;box-sizing:border-box;}'
			. '.hs-check{margin:16px 0;}.hs-check input{width:auto;max-width:none;margin-right:6px;}'
			. '.hs-button{display:inline-block;background:#1f6feb;color:#fff;border:0;border-radius:6px;padding:11px 20px;font-size:15px;cursor:pointer;text-decoration:none;}'
			. '.hs-button--small{padding:6px 12px;font-size:13px;}'
			. '.hs-button--link{background:none;color:#1f6feb;padding:8px 0;text-decoration:underline;}'
			. '.hs-button--danger{background:#8a2b2b;}'
			. '.hs-notice{padding:10px 14px;border-radius:6px;margin:16px 0;}'
			. '.hs-notice--ok{background:#e4f4e4;color:#256029;}'
			. '.hs-notice--warn{background:#fbf1d8;color:#7a5b12;}'
			. '.hs-foot{color:#999;font-size:12px;text-align:center;margin-top:24px;}'
			. '</style>';
		echo '</head><body><div class="hs-wrap">';
		if ( '' !== (string) $site ) {
			echo '<p class="hs-foot" style="margin-top:0;">' . esc_html( (string) $site ) . '</p>';
		}
	}

	/**
	 * Fuss der eigenstaendigen HTML-Seite.
	 *
	 * @return void
	 */
	private function render_foot(): void {
		echo '</div></body></html>';
	}
}
