<?php
/**
 * Wave Access – Vorlesen (Text-zu-Sprache).
 * Frontend-Endpunkt: Besucher klickt im Vorlese-Modus einen Textabschnitt an;
 * die Site holt das MP3 vom KI-Proxy und cacht es dauerhaft in uploads/wave-tts/.
 */
if ( ! defined( 'ABSPATH' ) ) { exit; }

class Wave_TTS {

	const MAX_CHARS = 4000;
	const RATE_MAX  = 40; // Anfragen pro IP und Stunde (nur für nicht gecachte Abschnitte)

	public static function init() {
		add_action( 'rest_api_init', array( __CLASS__, 'routes' ) );
	}

	/** Vorlesen verfügbar? (AI-Plan + Proxy-Zugang + Website-Schalter aktiv) */
	public static function available() {
		if ( ! Wave_License::is_ai() ) {
			return false;
		}
		$o = Wave_AI_Client::get_options();
		return ! empty( $o['tts'] ) && ! empty( $o['license_key'] ) && ! empty( $o['server_url'] );
	}

	public static function routes() {
		register_rest_route(
			'wave/v1',
			'/tts',
			array(
				'methods'             => 'POST',
				'permission_callback' => '__return_true',
				'callback'            => array( __CLASS__, 'rest_tts' ),
			)
		);
	}

	public static function rest_tts( WP_REST_Request $request ) {
		if ( ! self::available() ) {
			return new WP_REST_Response( array( 'ok' => false, 'message' => 'Vorlesen ist nicht verfügbar.' ), 403 );
		}

		$text = trim( wp_strip_all_tags( (string) $request->get_param( 'text' ) ) );
		$text = preg_replace( '/\s+/u', ' ', $text );
		if ( '' === $text ) {
			return new WP_REST_Response( array( 'ok' => false, 'message' => 'Kein Text.' ), 400 );
		}
		if ( mb_strlen( $text ) > self::MAX_CHARS ) {
			$text = mb_substr( $text, 0, self::MAX_CHARS );
		}

		// Dauerhafter Cache pro Textabschnitt: gleiche Passage wird nie zweimal erzeugt.
		$hash = md5( $text );
		$up   = wp_upload_dir();
		$dir  = trailingslashit( $up['basedir'] ) . 'wave-tts';
		$file = $dir . '/' . $hash . '.mp3';
		$url  = trailingslashit( $up['baseurl'] ) . 'wave-tts/' . $hash . '.mp3';
		$tmf  = $dir . '/' . $hash . '.json'; // Wort-Zeitmarken (Karaoke-Hervorhebung)

		if ( file_exists( $file ) ) {
			return new WP_REST_Response(
				array( 'ok' => true, 'url' => $url, 'cached' => true, 'timings' => self::read_timings( $tmf ) ),
				200
			);
		}

		// Rate-Limit nur für echte Proxy-Anfragen.
		$ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		$rk  = 'wave_tts_rate_' . md5( $ip );
		$cnt = (int) get_transient( $rk );
		if ( $cnt >= self::RATE_MAX ) {
			return new WP_REST_Response( array( 'ok' => false, 'message' => 'Zu viele Anfragen – bitte später erneut versuchen.' ), 429 );
		}
		set_transient( $rk, $cnt + 1, HOUR_IN_SECONDS );

		$result = Wave_AI_Client::tts( $text );
		if ( is_wp_error( $result ) ) {
			return new WP_REST_Response( array( 'ok' => false, 'message' => $result->get_error_message() ), 502 );
		}

		$audio = base64_decode( (string) $result['audio_b64'], true );
		if ( false === $audio || '' === $audio ) {
			return new WP_REST_Response( array( 'ok' => false, 'message' => 'Ungültige Audiodaten.' ), 502 );
		}

		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
		}
		if ( false === file_put_contents( $file, $audio ) ) {
			return new WP_REST_Response( array( 'ok' => false, 'message' => 'Audio konnte nicht gespeichert werden.' ), 500 );
		}

		// Wort-Zeitmarken vom Proxy auf die Anzeige-Wörter ausrichten und mitcachen.
		$timings = null;
		if ( ! empty( $result['words'] ) && is_array( $result['words'] ) ) {
			$timings = self::align_words( $text, $result['words'] );
			if ( $timings ) {
				file_put_contents( $tmf, wp_json_encode( array( 'v' => 1, 'timings' => $timings ) ) );
			}
		}

		return new WP_REST_Response( array( 'ok' => true, 'url' => $url, 'cached' => false, 'timings' => $timings ), 200 );
	}

	/** Gecachte Zeitmarken lesen (null, wenn nicht vorhanden/ungültig). */
	private static function read_timings( $tmf ) {
		if ( ! file_exists( $tmf ) ) {
			return null;
		}
		$j = json_decode( (string) file_get_contents( $tmf ), true );
		return ( is_array( $j ) && ! empty( $j['timings'] ) ) ? $j['timings'] : null;
	}

	/**
	 * Whisper-Tokens den Anzeige-Wörtern zuordnen (Tokenisierung weicht ab,
	 * z. B. „KI" + „-Stimme" → „KI-Stimme"). Greedy: Tokens aufsammeln, bis
	 * die normalisierte Länge des Zielworts erreicht ist.
	 *
	 * @param string $text    Whitespace-kollabierter Sprechtext.
	 * @param array  $whisper Liste aus [wort, start, ende].
	 * @return array|null Je Anzeige-Wort [start, ende] oder null.
	 */
	private static function align_words( $text, $whisper ) {
		$disp = explode( ' ', $text );
		$norm = function ( $s ) {
			return preg_replace( '/[^\p{L}\p{N}]+/u', '', mb_strtolower( (string) $s ) );
		};
		$timings = array();
		$i       = 0;
		$n       = count( $whisper );
		foreach ( $disp as $d ) {
			$tgt = $norm( $d );
			if ( '' === $tgt ) {
				$timings[] = null;
				continue;
			}
			$acc = '';
			$st  = null;
			$en  = null;
			while ( $i < $n ) {
				if ( null === $st ) {
					$st = (float) $whisper[ $i ][1];
				}
				$acc .= $norm( $whisper[ $i ][0] );
				$en   = (float) $whisper[ $i ][2];
				$i++;
				if ( $acc === $tgt || mb_strlen( $acc ) >= mb_strlen( $tgt ) ) {
					break;
				}
			}
			$timings[] = ( null === $st ) ? null : array( round( $st, 2 ), round( $en, 2 ) );
		}
		// Wenn die Tokens deutlich vor dem Textende ausgehen, lieber kein Karaoke.
		$mit = count( array_filter( $timings ) );
		return ( $mit >= max( 1, (int) ( count( $disp ) * 0.8 ) ) ) ? $timings : null;
	}
}

Wave_TTS::init();
