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

namespace Hafenstudios\Kurato\Db;

defined( 'ABSPATH' ) || exit;

/**
 * Buchungen (hs_bookings, K5a). Zeiten IMMER UTC (start_utc/end_utc).
 *
 * Doppelbuchungs-Schutz: der UNIQUE-Index (host_id, start_utc, status) laesst je
 * Host und Startzeit nur EINE confirmed-Buchung zu. Ein paralleler Submit auf
 * denselben frisch berechneten Slot scheitert am Insert; {@see create()} faengt
 * das ab und gibt 0 zurueck (Aufrufer zeigt "Slot ist soeben vergeben worden").
 * Muster wie der K3c-Race-Fallback beim Kontakt-Intake.
 *
 * Nicht final, damit Tests die DB-Methoden per Test-Double ueberschreiben koennen.
 */
class BookingRepository {

	/**
	 * Legt eine Buchung an (Status confirmed).
	 *
	 * Der Race-Abfang: schlaegt der Insert fehl (UNIQUE-Kollision, also ein
	 * anderer hat denselben Slot soeben belegt), wird 0 zurueckgegeben. Ein
	 * Rueckgabewert von 0 bedeutet damit "nicht angelegt" (inkl. Slot-Kollision);
	 * der Aufrufer behandelt das als "Slot vergeben".
	 *
	 * @param array<string, mixed> $data contact_id, host_id, type_id, start_utc,
	 *                                    end_utc, note. start_utc/end_utc als
	 *                                    'Y-m-d H:i:s' (UTC) oder DateTimeInterface.
	 * @return int Buchungs-ID (0 bei Fehler/Slot-Kollision).
	 */
	public function create( array $data ): int {
		$host_id = absint( $data['host_id'] ?? 0 );
		$type_id = absint( $data['type_id'] ?? 0 );
		$start   = $this->to_utc_string( $data['start_utc'] ?? '' );
		$end     = $this->to_utc_string( $data['end_utc'] ?? '' );
		if ( $host_id <= 0 || $type_id <= 0 || '' === $start || '' === $end ) {
			return 0;
		}

		$contact_id = absint( $data['contact_id'] ?? 0 );

		global $wpdb;
		// Kein Insert-Fehler soll als PHP-Warning nach aussen dringen; der UNIQUE-
		// Konflikt ist ein erwarteter Zustand (Race). $wpdb->insert liefert false.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$ok = $wpdb->insert(
			Schema::table( 'bookings' ),
			array(
				'contact_id'    => $contact_id > 0 ? $contact_id : null,
				'host_id'       => $host_id,
				'type_id'       => $type_id,
				'start_utc'     => $start,
				'end_utc'       => $end,
				'status'        => 'confirmed',
				'note'          => substr( sanitize_textarea_field( (string) ( $data['note'] ?? '' ) ), 0, 5000 ),
				'reminder_sent' => 0,
				'created_at'    => current_time( 'mysql', true ),
			)
		);
		return $ok ? (int) $wpdb->insert_id : 0;
	}

	/**
	 * Eine Buchung (auch fuer die Token-Aufloesung: BookingToken traegt die id).
	 *
	 * @param int $id Buchungs-ID.
	 * @return array<string, mixed>|null
	 */
	public function get( int $id ): ?array {
		global $wpdb;
		$table = Schema::table( 'bookings' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
		return $row ? $this->hydrate( $row ) : null;
	}

	/**
	 * Confirmed-Buchungen eines Hosts, die ein UTC-Zeitfenster ueberlappen -
	 * inklusive der Puffer der jeweiligen Terminart (fuer den SlotCalculator und
	 * die Buchungsliste). Puffer werden per LEFT JOIN aus hs_booking_types geholt;
	 * es zaehlt der Puffer der BESTEHENDEN Buchung (K5-KONZEPT Abschnitt 3).
	 *
	 * @param int    $host_id  Host-ID.
	 * @param string $from_utc Fensteranfang 'Y-m-d H:i:s' (UTC).
	 * @param string $to_utc   Fensterende 'Y-m-d H:i:s' (UTC).
	 * @return array<int, array<string, mixed>>
	 */
	public function bookings_for_host_between( int $host_id, string $from_utc, string $to_utc ): array {
		global $wpdb;
		$bookings = Schema::table( 'bookings' );
		$types    = Schema::table( 'booking_types' );
		// Ueberlappung: start < Fensterende UND end > Fensteranfang. Der Puffer der
		// Terminart weitet die effektive Belegung; darum wird das Fenster grosszuegig
		// gewaehlt (der SlotCalculator prueft die Puffer feinkoernig).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT b.*, t.buffer_before_min, t.buffer_after_min
				FROM {$bookings} b
				LEFT JOIN {$types} t ON t.id = b.type_id
				WHERE b.host_id = %d AND b.status = 'confirmed' AND b.start_utc < %s AND b.end_utc > %s
				ORDER BY b.start_utc ASC",
				$host_id,
				$to_utc,
				$from_utc
			),
			ARRAY_A
		);
		return array_map( array( $this, 'hydrate' ), $rows ?: array() );
	}

	/**
	 * Buchungen eines Kontakts (Timeline im Kontaktprofil), neueste zuerst.
	 *
	 * @param int $contact_id Kontakt-ID.
	 * @return array<int, array<string, mixed>>
	 */
	public function for_contact( int $contact_id ): array {
		global $wpdb;
		$table = Schema::table( 'bookings' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contact_id = %d ORDER BY start_utc DESC, id DESC", $contact_id ), ARRAY_A );
		return array_map( array( $this, 'hydrate' ), $rows ?: array() );
	}

	/**
	 * Bestaetigte Buchungen in einem UTC-Zeitfenster ueber ALLE Kontakte, inkl.
	 * Kontaktname (Join) - Grundlage der Termine-Ansicht im Cockpit (CP4).
	 * Gleiche geprufte SQL-Linie wie {@see due_for_reminder()}, nur mit Join und
	 * Limit; naechster Termin zuerst.
	 *
	 * @param string $from_utc Fensteranfang 'Y-m-d H:i:s' (UTC), inklusive.
	 * @param string $to_utc   Fensterende 'Y-m-d H:i:s' (UTC), exklusive.
	 * @param int    $limit    Obergrenze (1..500).
	 * @return array<int, array<string, mixed>>
	 */
	public function in_range( string $from_utc, string $to_utc, int $limit = 200 ): array {
		$limit = max( 1, min( 500, $limit ) );
		global $wpdb;
		$bookings = Schema::table( 'bookings' );
		$contacts = Schema::table( 'contacts' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT b.*, c.display_name, c.email
				FROM {$bookings} b
				LEFT JOIN {$contacts} c ON c.id = b.contact_id
				WHERE b.status = 'confirmed' AND b.start_utc >= %s AND b.start_utc < %s
				ORDER BY b.start_utc ASC, b.id ASC
				LIMIT %d",
				$from_utc,
				$to_utc,
				$limit
			),
			ARRAY_A
		);

		$out = array();
		foreach ( (array) $rows as $row ) {
			$booking = $this->hydrate( $row );
			$name    = trim( (string) ( $row['display_name'] ?? '' ) );
			if ( '' === $name ) {
				$name = (string) ( $row['email'] ?? '' );
			}
			$booking['contact_name'] = $name;
			$out[]                   = $booking;
		}
		return $out;
	}

	/**
	 * Storniert eine Buchung (Status cancelled + cancelled_at). Der Slot wird
	 * damit wieder buchbar (der UNIQUE-Index traegt den Status).
	 *
	 * @param int $id Buchungs-ID.
	 * @return bool
	 */
	public function cancel( int $id ): bool {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$affected = $wpdb->update(
			Schema::table( 'bookings' ),
			array(
				'status'       => 'cancelled',
				'cancelled_at' => current_time( 'mysql', true ),
			),
			array(
				'id'     => $id,
				'status' => 'confirmed',
			)
		);
		return is_int( $affected ) && $affected > 0;
	}

	/**
	 * Confirmed-Buchungen, deren Start in einem UTC-Fenster liegt und die noch
	 * keine Erinnerung erhalten haben (reminder_sent = 0). Grundlage des
	 * Erinnerungs-Crons (K5c); das Fenster verhindert Doppelversand.
	 *
	 * @param string $from_utc Fensteranfang 'Y-m-d H:i:s' (UTC).
	 * @param string $to_utc   Fensterende 'Y-m-d H:i:s' (UTC).
	 * @return array<int, array<string, mixed>>
	 */
	public function due_for_reminder( string $from_utc, string $to_utc ): array {
		global $wpdb;
		$table = Schema::table( 'bookings' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE status = 'confirmed' AND reminder_sent = 0 AND start_utc >= %s AND start_utc < %s ORDER BY start_utc ASC",
				$from_utc,
				$to_utc
			),
			ARRAY_A
		);
		return array_map( array( $this, 'hydrate' ), $rows ?: array() );
	}

	/**
	 * Markiert eine Buchung als "Erinnerung versendet".
	 *
	 * @param int $id Buchungs-ID.
	 * @return bool
	 */
	public function mark_reminder_sent( int $id ): bool {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$affected = $wpdb->update( Schema::table( 'bookings' ), array( 'reminder_sent' => 1 ), array( 'id' => $id ) );
		return is_int( $affected ) && $affected > 0;
	}

	/**
	 * Loescht vergangene Buchungen, deren Start aelter als die Aufbewahrungsfrist
	 * ist (Retention-Cron K5c, Setting booking.retention_days). 0 = keine Loeschung.
	 *
	 * @param int $days Frist in Tagen.
	 * @return int Anzahl geloeschter Buchungen.
	 */
	public function delete_past_older_than_days( int $days ): int {
		if ( $days <= 0 ) {
			return 0;
		}
		global $wpdb;
		$table  = Schema::table( 'bookings' );
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE start_utc < %s", $cutoff ) );
		return is_int( $deleted ) ? $deleted : 0;
	}

	/**
	 * Loescht alle Buchungen eines Kontakts (Recht auf Vergessen; spaeter aus der
	 * PrivacyBridge, K5c).
	 *
	 * @param int $contact_id Kontakt-ID.
	 * @return void
	 */
	public function delete_by_contact( int $contact_id ): void {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->delete( Schema::table( 'bookings' ), array( 'contact_id' => $contact_id ) );
	}

	/**
	 * Anonymisiert die Buchungen eines Kontakts: loest den Personenbezug
	 * (contact_id => NULL) und entfernt die freie Notiz, behaelt aber die Termin-
	 * Kennzahlen (fuer Statistik/Nachvollzug). Alternative zum harten Loeschen in
	 * der spaeteren PrivacyBridge (K5c).
	 *
	 * @param int $contact_id Kontakt-ID.
	 * @return void
	 */
	public function anonymize_by_contact( int $contact_id ): void {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			Schema::table( 'bookings' ),
			array(
				'contact_id' => null,
				'note'       => '',
			),
			array( 'contact_id' => $contact_id )
		);
	}

	/**
	 * Wandelt eine Zeitangabe in einen UTC-'Y-m-d H:i:s'-String. Akzeptiert
	 * DateTimeInterface (wird nach UTC gerueckt) oder einen bereits UTC-gemeinten
	 * String. Ungueltig => ''.
	 *
	 * @param mixed $value Zeit.
	 * @return string
	 */
	private function to_utc_string( $value ): string {
		if ( $value instanceof \DateTimeInterface ) {
			$dt = \DateTimeImmutable::createFromInterface( $value )->setTimezone( new \DateTimeZone( 'UTC' ) );
			return $dt->format( 'Y-m-d H:i:s' );
		}
		$value = trim( (string) $value );
		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value ) ) {
			return '';
		}
		return $value;
	}

	/**
	 * Normalisiert eine Zeile.
	 *
	 * @param array<string, mixed> $row Rohzeile.
	 * @return array<string, mixed>
	 */
	private function hydrate( array $row ): array {
		$out = array(
			'id'            => (int) $row['id'],
			'contact_id'    => null !== $row['contact_id'] ? (int) $row['contact_id'] : null,
			'host_id'       => (int) $row['host_id'],
			'type_id'       => (int) $row['type_id'],
			'start_utc'     => (string) $row['start_utc'],
			'end_utc'       => (string) $row['end_utc'],
			'status'        => (string) $row['status'],
			'note'          => (string) ( $row['note'] ?? '' ),
			'reminder_sent' => (int) ( $row['reminder_sent'] ?? 0 ),
			'created_at'    => (string) ( $row['created_at'] ?? '' ),
			'cancelled_at'  => ! empty( $row['cancelled_at'] ) ? (string) $row['cancelled_at'] : null,
		);
		// Aus dem JOIN in bookings_for_host_between: Puffer der Terminart.
		if ( array_key_exists( 'buffer_before_min', $row ) ) {
			$out['buffer_before_min'] = (int) $row['buffer_before_min'];
			$out['buffer_after_min']  = (int) ( $row['buffer_after_min'] ?? 0 );
		}
		return $out;
	}
}
