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

namespace Hafenstudios\Kurato\Cockpit;

defined( 'ABSPATH' ) || exit;

/**
 * Prueft ein Site-Ticket stateless nach COCKPIT-API.md (Abschnitt "Site-Ticket").
 *
 * Format: base64url(payload_json).base64url(hmac_sha256(site_key, payload_json)).
 * Bewusst KEIN JWT: kein alg-Feld, keine Bibliothek; der Algorithmus ist fest
 * HMAC-SHA256. Damit existiert die ganze Klasse der alg-Verwechslungs-Angriffe
 * nicht.
 *
 * Prueft die Schritte 1-4 des Vertrags (Format, HMAC, v/key_version, Zeitfenster).
 * Schritt 5 (Nutzer existiert/Rolle/aktiv) macht der Authenticator, weil er
 * WordPress-Nutzerdaten braucht. Jeder Fehlschlag liefert generisch
 * invalid_ticket bzw. bei Zeitfenster-Verletzung expired_ticket - ohne
 * Detailgrund nach aussen.
 */
class TicketVerifier {

	/** Uhren-Toleranz in Sekunden (nbf-Fenster). */
	private const SKEW = 300;

	private string $site_key;

	private int $key_version;

	private Clock $clock;

	/**
	 * @param string $site_key    Gemeinsames Geheimnis der gekoppelten Site.
	 * @param int    $key_version Aktuelle Schluessel-Version.
	 * @param Clock  $clock       Zeit-Seam.
	 */
	public function __construct( string $site_key, int $key_version, Clock $clock ) {
		$this->site_key    = $site_key;
		$this->key_version = $key_version;
		$this->clock       = $clock;
	}

	/**
	 * Prueft ein Bearer-Ticket.
	 *
	 * @param string $token Der rohe Ticket-String (ohne "Bearer ").
	 * @return array<string, mixed>|\WP_Error Payload bei Erfolg, sonst WP_Error.
	 */
	public function verify( string $token ) {
		// 1. Format und Base64url-Dekodierung.
		$parts = explode( '.', $token );
		if ( 2 !== count( $parts ) || '' === $parts[0] || '' === $parts[1] ) {
			return $this->invalid();
		}
		$payload_raw = self::b64url_decode( $parts[0] );
		$signature   = self::b64url_decode( $parts[1] );
		if ( null === $payload_raw || null === $signature ) {
			return $this->invalid();
		}

		// 2. HMAC-Vergleich (timing-sicher).
		$expected = hash_hmac( 'sha256', $payload_raw, $this->site_key, true );
		if ( ! hash_equals( $expected, $signature ) ) {
			return $this->invalid();
		}

		$payload = json_decode( $payload_raw, true );
		if ( ! is_array( $payload ) ) {
			return $this->invalid();
		}

		// 3. v bekannt, key_version == gespeicherte Version.
		if ( 1 !== (int) ( $payload['v'] ?? 0 ) ) {
			return $this->invalid();
		}
		if ( (int) ( $payload['key_version'] ?? 0 ) !== $this->key_version ) {
			return $this->invalid();
		}

		// 4. Zeitfenster nbf-300 <= now <= exp.
		$now = $this->clock->now();
		$nbf = (int) ( $payload['nbf'] ?? 0 );
		$exp = (int) ( $payload['exp'] ?? 0 );
		if ( $exp <= 0 || $now > $exp ) {
			return $this->expired();
		}
		if ( $now < $nbf - self::SKEW ) {
			return $this->expired();
		}

		return $payload;
	}

	/**
	 * Signiert einen Payload zum Ticket-String. Nur der Dienst stellt Tickets
	 * aus; diese Methode existiert fuer die Vertragstests (und spaetere
	 * Dienst-Wiederverwendung).
	 *
	 * @param array<string, mixed> $payload  Ticket-Payload.
	 * @param string               $site_key Geheimnis.
	 * @return string
	 */
	public static function sign( array $payload, string $site_key ): string {
		$raw = (string) wp_json_encode( $payload );
		return self::b64url_encode( $raw ) . '.' . self::b64url_encode( hash_hmac( 'sha256', $raw, $site_key, true ) );
	}

	/**
	 * Generischer Ticket-Fehler (kein Detailgrund nach aussen).
	 *
	 * @return \WP_Error
	 */
	private function invalid(): \WP_Error {
		return new \WP_Error(
			'invalid_ticket',
			__( 'Anmeldung nicht moeglich. Bitte melden Sie sich im Cockpit neu an.', 'kurato' ),
			array( 'status' => 401 )
		);
	}

	/**
	 * Zeitfenster verletzt: die App holt ein frisches Ticket und wiederholt.
	 *
	 * @return \WP_Error
	 */
	private function expired(): \WP_Error {
		return new \WP_Error(
			'expired_ticket',
			__( 'Sitzung abgelaufen. Bitte neu anmelden.', 'kurato' ),
			array( 'status' => 401 )
		);
	}

	/**
	 * Base64url-Dekodierung (ohne Padding). Liefert null bei ungueltiger Eingabe.
	 *
	 * @param string $data Eingabe.
	 * @return string|null
	 */
	private static function b64url_decode( string $data ): ?string {
		$remainder = strlen( $data ) % 4;
		if ( $remainder ) {
			$data .= str_repeat( '=', 4 - $remainder );
		}
		$decoded = base64_decode( strtr( $data, '-_', '+/' ), true );
		return false === $decoded ? null : $decoded;
	}

	/**
	 * Base64url-Kodierung (ohne Padding).
	 *
	 * @param string $data Eingabe.
	 * @return string
	 */
	private static function b64url_encode( string $data ): string {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}
}
