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

namespace Hafenstudios\Kurato\Cockpit;

defined( 'ABSPATH' ) || exit;

/**
 * Bearer-Ticket-Anmeldung fuer die Cockpit-App (COCKPIT-API.md).
 *
 * Haengt an determine_current_user und setzt den Schattennutzer NUR fuer
 * Requests an die Namespaces kurato/v1 und kurato-cockpit/v1 (harter
 * Namespace-Check VOR der Nutzerzuordnung). Fuer /wp/v2 und alles andere ist
 * das Ticket wirkungslos.
 *
 * Fehlschlaege liefern generisch invalid_ticket bzw. expired_ticket ueber
 * rest_authentication_errors, ohne Detailgrund nach aussen.
 */
class Authenticator {

	private TicketVerifier $verifier;

	private UserDirectory $users;

	private bool $evaluated = false;

	private ?int $resolved_user = null;

	private ?\WP_Error $error = null;

	/**
	 * @param TicketVerifier $verifier Ticket-Pruefung (Schritte 1-4).
	 * @param UserDirectory  $users    Nutzer-Pruefung (Schritt 5).
	 */
	public function __construct( TicketVerifier $verifier, UserDirectory $users ) {
		$this->verifier = $verifier;
		$this->users    = $users;
	}

	/**
	 * Registriert die Auth-Hooks (nur bei abgeschlossenem Pairing aufrufen).
	 *
	 * @return void
	 */
	public function register(): void {
		add_filter( 'determine_current_user', array( $this, 'on_determine_current_user' ), 20 );
		add_filter( 'rest_authentication_errors', array( $this, 'on_rest_authentication_errors' ), 20 );
	}

	/**
	 * determine_current_user: setzt den Schattennutzer bei gueltigem Ticket.
	 *
	 * @param int|false $user Bisher ermittelter Nutzer.
	 * @return int|false
	 */
	public function on_determine_current_user( $user ) {
		// Ein bereits per Cookie angemeldeter Nutzer bleibt unangetastet.
		if ( ! empty( $user ) ) {
			return $user;
		}
		$this->evaluate();
		return $this->resolved_user ?? $user;
	}

	/**
	 * rest_authentication_errors: liefert den generischen Ticket-Fehler.
	 *
	 * @param \WP_Error|null|true $errors Bisheriger Auth-Status.
	 * @return \WP_Error|null|true
	 */
	public function on_rest_authentication_errors( $errors ) {
		if ( is_wp_error( $errors ) ) {
			return $errors;
		}
		$this->evaluate();
		if ( null === $this->error ) {
			return $errors;
		}
		// Einen bereits per Cookie angemeldeten Nutzer (etwa der Inhaber im
		// Backend, der zufaellig ein Ticket mitschickt) nie wegen des Tickets
		// abweisen - der Ticket-Fehler gilt nur fuer sonst unauthentifizierte
		// App-Requests.
		if ( function_exists( 'get_current_user_id' ) && get_current_user_id() > 0 ) {
			return $errors;
		}
		return $this->error;
	}

	/**
	 * Wertet den aktuellen Request einmalig aus (memoisiert).
	 *
	 * @return void
	 */
	private function evaluate(): void {
		if ( $this->evaluated ) {
			return;
		}
		$this->evaluated = true;

		$route  = self::current_route();
		$bearer = self::bearer_from_server( $_SERVER );

		$result = $this->resolve( $route, $bearer );

		$this->resolved_user = $result['user'];
		$this->error         = $result['error'];
	}

	/**
	 * Reine Aufloesung: ordnet Route + Bearer einem Nutzer oder Fehler zu.
	 *
	 * @param string $route  REST-Route des Requests.
	 * @param string $bearer Roher Bearer-Wert (leer wenn keiner).
	 * @return array{user: int|null, error: \WP_Error|null}
	 */
	public function resolve( string $route, string $bearer ): array {
		// Harter Namespace-Check VOR der Nutzerzuordnung.
		if ( ! self::is_cockpit_route( $route ) || '' === $bearer ) {
			return array(
				'user'  => null,
				'error' => null,
			);
		}

		$payload = $this->verifier->verify( $bearer );
		if ( is_wp_error( $payload ) ) {
			return array(
				'user'  => null,
				'error' => $payload,
			);
		}

		$wp_user_id = (int) ( $payload['wp_user_id'] ?? 0 );
		if ( $wp_user_id <= 0 || ! $this->users->is_valid_agent( $wp_user_id ) ) {
			// Schritt 5 fehlgeschlagen: generischer Sammelcode, kein Detailgrund.
			return array(
				'user'  => null,
				'error' => new \WP_Error(
					'invalid_ticket',
					__( 'Anmeldung nicht moeglich. Bitte melden Sie sich im Cockpit neu an.', 'kurato' ),
					array( 'status' => 401 )
				),
			);
		}

		return array(
			'user'  => $wp_user_id,
			'error' => null,
		);
	}

	/**
	 * Gehoert die Route zu den beiden Kurato-Namespaces?
	 *
	 * @param string $route REST-Route (mit oder ohne fuehrenden Slash).
	 * @return bool
	 */
	public static function is_cockpit_route( string $route ): bool {
		$route = ltrim( $route, '/' );
		foreach ( array( 'kurato/v1', 'kurato-cockpit/v1' ) as $namespace ) {
			if ( $route === $namespace || 0 === strpos( $route, $namespace . '/' ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Liest den Bearer-Wert aus den Server-Kopfzeilen.
	 *
	 * @param array<string, mixed> $server $_SERVER-Abbild.
	 * @return string
	 */
	public static function bearer_from_server( array $server ): string {
		$header = '';
		if ( isset( $server['HTTP_AUTHORIZATION'] ) ) {
			$header = (string) $server['HTTP_AUTHORIZATION'];
		} elseif ( isset( $server['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
			$header = (string) $server['REDIRECT_HTTP_AUTHORIZATION'];
		}
		if ( 0 === stripos( $header, 'Bearer ' ) ) {
			return trim( substr( $header, 7 ) );
		}
		return '';
	}

	/**
	 * Ermittelt die aktuelle REST-Route (ohne /wp-json-Praefix).
	 *
	 * @return string
	 */
	private static function current_route(): string {
		$query_vars = isset( $GLOBALS['wp'] ) && isset( $GLOBALS['wp']->query_vars ) ? (array) $GLOBALS['wp']->query_vars : array();
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nur Routen-Erkennung, keine Aktion.
		return self::route_from_request( $query_vars, $_GET, $_SERVER );
	}

	/**
	 * Ermittelt die REST-Route aus den drei moeglichen Quellen. Rein und testbar.
	 *
	 * WICHTIG (Live-Fund kurato.cloud, 24.07.2026): `determine_current_user` laeuft
	 * regelmaessig BEVOR WordPress die Anfrage geparst hat - `query_vars['rest_route']`
	 * ist dann leer. Verlaesst man sich nur darauf, wird das Ticket nie angewendet
	 * und die App bekommt auf jeder kurato/v1-Route 401 (Board blieb leer). Deshalb
	 * zusaetzlich der Query-Parameter (unhuebsche Permalinks) und der Pfad aus
	 * REQUEST_URI (huebsche Permalinks) als Quelle. Der harte Namespace-Check
	 * bleibt unveraendert - das Ticket wirkt weiterhin NUR auf kurato/v1 und
	 * kurato-cockpit/v1.
	 *
	 * @param array<string, mixed> $query_vars WordPress-Query-Variablen.
	 * @param array<string, mixed> $get        $_GET-Abbild.
	 * @param array<string, mixed> $server     $_SERVER-Abbild.
	 * @return string Route ohne fuehrenden Slash und ohne /wp-json-Praefix.
	 */
	public static function route_from_request( array $query_vars, array $get, array $server ): string {
		if ( ! empty( $query_vars['rest_route'] ) ) {
			return ltrim( (string) $query_vars['rest_route'], '/' );
		}
		if ( ! empty( $get['rest_route'] ) ) {
			return ltrim( (string) $get['rest_route'], '/' );
		}

		$uri = isset( $server['REQUEST_URI'] ) ? (string) $server['REQUEST_URI'] : '';
		if ( '' === $uri ) {
			return '';
		}
		$path   = (string) wp_parse_url( $uri, PHP_URL_PATH );
		$prefix = '/' . ( function_exists( 'rest_get_url_prefix' ) ? rest_get_url_prefix() : 'wp-json' ) . '/';
		$pos    = strpos( $path, $prefix );
		if ( false === $pos ) {
			return '';
		}
		return ltrim( substr( $path, $pos + strlen( $prefix ) ), '/' );
	}
}
