<?php
/**
 * Direkter OAuth-2.1-Pfad für MCP-Clients. Implementiert RFC 8414, RFC 9728,
 * RFC 7591, RFC 8628, PKCE S256, Resource Indicators und Refresh-Token-Rotation.
 *
 * @package WPAgentCompanion
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

function wpagent_companion_oauth_issuer(): string {
	return untrailingslashit( home_url() );
}

/** @return string[] */
function wpagent_companion_oauth_supported_scopes(): array {
	return array( 'wpagently', 'offline_access' );
}

/** @return string[] */
function wpagent_companion_oauth_supported_grant_types(): array {
	return array( 'authorization_code', 'refresh_token', WPAGENT_COMPANION_OAUTH_DEVICE_GRANT );
}

/** @return string[] */
function wpagent_companion_oauth_client_grant_types( array $client ): array {
	$stored = $client['grant_types'] ?? null;
	if ( ! is_array( $stored ) || array() === $stored ) {
		// Vor RFC-8628 registrierte Clients bleiben mit dem bisherigen Browser-
		// und Refresh-Flow kompatibel, erhalten aber keinen Device-Grant.
		return array( 'authorization_code', 'refresh_token' );
	}
	$allowed = wpagent_companion_oauth_supported_grant_types();
	return array_values( array_intersect( $allowed, array_filter( $stored, 'is_string' ) ) );
}

function wpagent_companion_oauth_client_allows_grant( array $client, string $grant_type ): bool {
	return in_array( $grant_type, wpagent_companion_oauth_client_grant_types( $client ), true );
}

/** @return string|WP_Error */
function wpagent_companion_oauth_normalize_scope( $scope ) {
	if ( null === $scope ) {
		return 'wpagently';
	}
	if ( ! is_string( $scope ) || strlen( $scope ) > 1024 ) {
		return new WP_Error( 'invalid_scope', __( 'Nicht unterstützter OAuth-Scope.', 'wp-agent-companion' ) );
	}
	if ( '' === trim( $scope ) ) {
		return 'wpagently';
	}
	$requested = preg_split( '/\s+/', trim( $scope ) );
	if ( ! is_array( $requested ) ) {
		return new WP_Error( 'invalid_scope', __( 'Nicht unterstützter OAuth-Scope.', 'wp-agent-companion' ) );
	}
	$requested = array_values( array_unique( $requested ) );
	$supported = wpagent_companion_oauth_supported_scopes();
	if ( array_diff( $requested, $supported ) || ! in_array( 'wpagently', $requested, true ) ) {
		return new WP_Error( 'invalid_scope', __( 'Nicht unterstützter OAuth-Scope.', 'wp-agent-companion' ) );
	}
	return implode( ' ', array_values( array_intersect( $supported, $requested ) ) );
}

function wpagent_companion_oauth_scope_contains( string $scope, string $required ): bool {
	$values = preg_split( '/\s+/', trim( $scope ) );
	return is_array( $values ) && in_array( $required, $values, true );
}

/** @return array<string,mixed> */
function wpagent_companion_oauth_rest_resource(): string {
	// determine_current_user kann bereits während plugins_loaded laufen. Zu
	// diesem Zeitpunkt ist $wp_rewrite noch nicht garantiert initialisiert und
	// rest_url() kann in WordPress deshalb fatal abbrechen. Der Companion setzt
	// ohnehin eine funktionierende /wp-json/-Route voraus, die Doctor separat
	// prüft. home_url() plus der Core-Präfix liefert dieselbe kanonische
	// Ressourcen-URL, ist aber schon im frühen Auth-Bootstrap sicher verfügbar.
	return untrailingslashit( home_url( '/' . trim( rest_get_url_prefix(), '/' ) ) );
}

function wpagent_companion_oauth_mcp_resource(): string {
	return wpagent_companion_oauth_rest_resource() . '/wp-agent/mcp';
}

/** @return string[] */
function wpagent_companion_oauth_resources(): array {
	return array( wpagent_companion_oauth_mcp_resource(), wpagent_companion_oauth_rest_resource() );
}

function wpagent_companion_oauth_protected_resource_metadata( string $resource = '' ): array {
	$resource = '' === $resource ? wpagent_companion_oauth_mcp_resource() : untrailingslashit( $resource );
	if ( ! in_array( $resource, wpagent_companion_oauth_resources(), true ) ) {
		$resource = wpagent_companion_oauth_mcp_resource();
	}
	return array(
		'resource'                 => $resource,
		'authorization_servers'    => array( wpagent_companion_oauth_issuer() ),
		'bearer_methods_supported' => array( 'header' ),
		// offline_access is an authorization-server concern. Advertising it as
		// a protected-resource requirement can make clients request it for every
		// 401 challenge even though the MCP resource only requires wpagently.
		'scopes_supported'         => array( 'wpagently' ),
		'resource_name'            => wpagent_companion_oauth_rest_resource() === $resource ? 'wpagently WordPress REST' : 'wpagently WordPress MCP',
		'resource_documentation'   => 'https://wpagently.com/#ablauf',
	);
}

/** @return array<string,mixed> */
function wpagent_companion_oauth_server_metadata(): array {
	return array(
		'issuer'                                => wpagent_companion_oauth_issuer(),
		'authorization_endpoint'                => add_query_arg( 'page', 'wpagently-oauth-authorize', admin_url( 'admin.php' ) ),
		'token_endpoint'                        => rest_url( 'wp-agent/oauth/token' ),
		'device_authorization_endpoint'         => rest_url( 'wp-agent/oauth/device_authorization' ),
		'registration_endpoint'                 => rest_url( 'wp-agent/oauth/register' ),
		'revocation_endpoint'                   => rest_url( 'wp-agent/oauth/revoke' ),
		'response_types_supported'              => array( 'code' ),
		'grant_types_supported'                 => wpagent_companion_oauth_supported_grant_types(),
		'token_endpoint_auth_methods_supported' => array( 'none' ),
		'code_challenge_methods_supported'      => array( 'S256' ),
		'scopes_supported'                      => wpagent_companion_oauth_supported_scopes(),
		'protected_resources'                   => wpagent_companion_oauth_resources(),
		'authorization_response_iss_parameter_supported' => true,
	);
}

function wpagent_companion_oauth_error_response( string $error, string $description, int $status = 400 ): WP_REST_Response {
	$response = new WP_REST_Response(
		array(
			'error'             => $error,
			'error_description' => $description,
		),
		$status
	);
	$response->header( 'Cache-Control', 'no-store' );
	$response->header( 'Pragma', 'no-cache' );
	return $response;
}

/** @return array<string,mixed> */
function wpagent_companion_oauth_json( WP_REST_Request $request ): array {
	$params = $request->get_json_params();
	return is_array( $params ) ? $params : array();
}

function wpagent_companion_oauth_is_string_list( $value ): bool {
	if ( ! is_array( $value ) || empty( $value ) ) {
		return false;
	}
	foreach ( $value as $item ) {
		if ( ! is_string( $item ) ) {
			return false;
		}
	}
	return true;
}

function wpagent_companion_oauth_valid_redirect_uri( string $uri ): bool {
	if ( strlen( $uri ) > 2048 || false !== strpos( $uri, '#' ) ) {
		return false;
	}
	$parts = wp_parse_url( $uri );
	if ( ! is_array( $parts ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || ! isset( $parts['scheme'], $parts['host'] ) ) {
		return false;
	}
	$scheme = strtolower( (string) $parts['scheme'] );
	$host   = strtolower( rtrim( (string) $parts['host'], '.' ) );
	if ( 'https' === $scheme ) {
		return true;
	}
	return 'http' === $scheme && in_array( $host, array( '127.0.0.1', '::1', 'localhost' ), true );
}

function wpagent_companion_oauth_register_client( WP_REST_Request $request ): WP_REST_Response {
	if ( ! wpagent_companion_gate_open() ) {
		return wpagent_companion_oauth_error_response( 'temporarily_unavailable', __( 'Die wpagently-Verbindung ist auf dieser Website geschlossen.', 'wp-agent-companion' ), 503 );
	}
	if ( ! is_ssl() && 'local' !== wp_get_environment_type() ) {
		return wpagent_companion_oauth_error_response( 'invalid_client_metadata', __( 'OAuth benötigt HTTPS.', 'wp-agent-companion' ) );
	}
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return wpagent_companion_oauth_error_response( $lock->get_error_code(), $lock->get_error_message(), (int) ( $lock->get_error_data()['status'] ?? 503 ) );
	}
	try {
		$allowed = wpagent_companion_oauth_consume_registration_slot_locked();
		if ( is_wp_error( $allowed ) ) {
			return wpagent_companion_oauth_error_response( $allowed->get_error_code(), $allowed->get_error_message(), (int) ( $allowed->get_error_data()['status'] ?? 500 ) );
		}
		$params = wpagent_companion_oauth_json( $request );
		if ( isset( $params['token_endpoint_auth_method'] ) && 'none' !== $params['token_endpoint_auth_method'] ) {
			return wpagent_companion_oauth_error_response( 'invalid_client_metadata', __( 'Nur öffentliche Clients ohne Client-Secret werden unterstützt.', 'wp-agent-companion' ) );
		}
		$grant_types = $params['grant_types'] ?? array( 'authorization_code', 'refresh_token' );
		if ( ! wpagent_companion_oauth_is_string_list( $grant_types ) || array_diff( $grant_types, wpagent_companion_oauth_supported_grant_types() ) || ! array_intersect( $grant_types, array( 'authorization_code', WPAGENT_COMPANION_OAUTH_DEVICE_GRANT ) ) ) {
			return wpagent_companion_oauth_error_response( 'invalid_client_metadata', __( 'Nicht unterstützter Grant-Typ.', 'wp-agent-companion' ) );
		}
		$grant_types = array_values( array_unique( $grant_types ) );
		$uses_authorization_code = in_array( 'authorization_code', $grant_types, true );
		$redirect_uris = $params['redirect_uris'] ?? ( $uses_authorization_code ? null : array() );
		if ( ! is_array( $redirect_uris ) || count( $redirect_uris ) > 10 || ( $uses_authorization_code && empty( $redirect_uris ) ) ) {
			return wpagent_companion_oauth_error_response( 'invalid_redirect_uri', __( 'redirect_uris muss für den Authorization-Code-Flow eine nicht leere Liste enthalten.', 'wp-agent-companion' ) );
		}
		$normalized = array();
		foreach ( $redirect_uris as $uri ) {
			if ( ! is_string( $uri ) || ! wpagent_companion_oauth_valid_redirect_uri( $uri ) ) {
				return wpagent_companion_oauth_error_response( 'invalid_redirect_uri', __( 'Eine Redirect-URI ist nicht erlaubt.', 'wp-agent-companion' ) );
			}
			$normalized[] = $uri;
		}
		$normalized = array_values( array_unique( $normalized ) );
		$response_types = $params['response_types'] ?? ( $uses_authorization_code ? array( 'code' ) : array() );
		if ( ! is_array( $response_types ) || array_filter( $response_types, 'is_string' ) !== $response_types || ( $uses_authorization_code && ( array_diff( $response_types, array( 'code' ) ) || ! in_array( 'code', $response_types, true ) ) ) || ( ! $uses_authorization_code && array() !== $response_types ) ) {
			return wpagent_companion_oauth_error_response( 'invalid_client_metadata', __( 'Nicht unterstützter Response-Typ.', 'wp-agent-companion' ) );
		}

		$client_id = 'wpagently_' . wpagent_companion_oauth_random( 24 );
		$name = sanitize_text_field( is_string( $params['client_name'] ?? null ) ? $params['client_name'] : 'MCP client' );
		$client = array(
			'client_id'     => $client_id,
			'client_name'   => wp_html_excerpt( $name ?: 'MCP client', 255, '' ),
			'redirect_uris' => $normalized,
			'grant_types'    => $grant_types,
			'response_types' => $response_types,
			'created_at'    => gmdate( 'c' ),
			'revoked_at'    => '',
		);
		$stored = wpagent_companion_oauth_store_client_locked( $client );
		if ( is_wp_error( $stored ) ) {
			$stored = wpagent_companion_oauth_public_storage_error( $stored );
			return wpagent_companion_oauth_error_response( $stored->get_error_code(), $stored->get_error_message(), (int) ( $stored->get_error_data()['status'] ?? 500 ) );
		}
		return new WP_REST_Response(
			array(
				'client_id'                  => $client_id,
				'client_id_issued_at'        => time(),
				'client_name'                => $client['client_name'],
				'redirect_uris'              => $normalized,
				'grant_types'                => $grant_types,
				'response_types'             => $response_types,
				'token_endpoint_auth_method' => 'none',
			),
			201
		);
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

/** @return array<string,string>|WP_Error */
function wpagent_companion_oauth_validate_authorization_request( array $params ) {
	$client_id = is_string( $params['client_id'] ?? null ) ? $params['client_id'] : '';
	$client = wpagent_companion_oauth_clients()[ $client_id ] ?? null;
	if ( ! is_array( $client ) || '' !== (string) ( $client['revoked_at'] ?? '' ) || ! wpagent_companion_oauth_client_allows_grant( $client, 'authorization_code' ) ) {
		return new WP_Error( 'invalid_client', __( 'Unbekannter OAuth-Client.', 'wp-agent-companion' ) );
	}
	$redirect_uri = is_string( $params['redirect_uri'] ?? null ) ? $params['redirect_uri'] : '';
	if ( ! in_array( $redirect_uri, (array) ( $client['redirect_uris'] ?? array() ), true ) ) {
		return new WP_Error( 'invalid_request', __( 'Die Redirect-URI stimmt nicht mit der Registrierung überein.', 'wp-agent-companion' ) );
	}
	$challenge = is_string( $params['code_challenge'] ?? null ) ? $params['code_challenge'] : '';
	if ( 'code' !== ( $params['response_type'] ?? '' ) || 'S256' !== ( $params['code_challenge_method'] ?? '' ) || ! preg_match( '/^[A-Za-z0-9._~-]{43,128}$/', $challenge ) ) {
		return new WP_Error( 'invalid_request', __( 'OAuth benötigt Authorization Code mit PKCE S256.', 'wp-agent-companion' ) );
	}
	$resource = is_string( $params['resource'] ?? null ) ? untrailingslashit( $params['resource'] ) : '';
	if ( '' === $resource ) {
		$resource = wpagent_companion_oauth_mcp_resource();
	}
	if ( ! in_array( $resource, wpagent_companion_oauth_resources(), true ) ) {
		return new WP_Error( 'invalid_target', __( 'Der angeforderte OAuth-Endpunkt ist nicht erlaubt.', 'wp-agent-companion' ) );
	}
	$scope = wpagent_companion_oauth_normalize_scope( $params['scope'] ?? null );
	if ( is_wp_error( $scope ) ) {
		return $scope;
	}
	if ( wpagent_companion_oauth_scope_contains( $scope, 'offline_access' ) && ! wpagent_companion_oauth_client_allows_grant( $client, 'refresh_token' ) ) {
		return new WP_Error( 'invalid_scope', __( 'Dieser OAuth-Client darf keine dauerhafte Verbindung anfordern.', 'wp-agent-companion' ) );
	}
	$state = is_string( $params['state'] ?? null ) ? $params['state'] : '';
	if ( strlen( $state ) > 1024 || str_contains( $state, "\r" ) || str_contains( $state, "\n" ) ) {
		return new WP_Error( 'invalid_request', __( 'Ungültiger OAuth-State.', 'wp-agent-companion' ) );
	}
	return array(
		'client_id'             => $client_id,
		'client_name'           => (string) ( $client['client_name'] ?? 'MCP client' ),
		'redirect_uri'          => $redirect_uri,
		'code_challenge'        => $challenge,
		'code_challenge_method' => 'S256',
		'resource'              => $resource,
		'scope'                 => $scope,
		'state'                 => $state,
	);
}

/** @return array{code:string,state:string,redirect_uri:string}|WP_Error */
function wpagent_companion_oauth_issue_code( array $params, int $user_id ) {
	$validated = wpagent_companion_oauth_validate_authorization_request( $params );
	if ( is_wp_error( $validated ) ) {
		return $validated;
	}
	$user = get_user_by( 'id', $user_id );
	if ( ! $user || ! wpagent_companion_is_safe_connection_user( $user ) || ! wpagent_companion_gate_open() ) {
		return new WP_Error( 'access_denied', __( 'Der gewählte Agent-Nutzer ist nicht freigegeben.', 'wp-agent-companion' ) );
	}
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return $lock;
	}
	try {
		$pruned = wpagent_companion_oauth_prune_locked();
		if ( is_wp_error( $pruned ) ) {
			return $pruned;
		}
		$codes = wpagent_companion_oauth_codes();
		if ( count( $codes ) >= wpagent_companion_oauth_limit() ) {
			return new WP_Error( 'temporarily_unavailable', __( 'Zu viele offene OAuth-Anmeldungen.', 'wp-agent-companion' ) );
		}
		$code = 'wpc_' . wpagent_companion_oauth_random();
		$codes[ wpagent_companion_oauth_hash( $code ) ] = array_merge(
			$validated,
			array( 'user_id' => $user_id, 'expires_at' => time() + WPAGENT_COMPANION_OAUTH_CODE_TTL )
		);
		$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_CODES, $codes );
		if ( is_wp_error( $stored ) ) {
			return $stored;
		}
		return array( 'code' => $code, 'state' => $validated['state'], 'redirect_uri' => $validated['redirect_uri'] );
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

function wpagent_companion_oauth_pkce( string $verifier ): string {
	return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
}

/**
 * @param array{access:array<string,array<string,mixed>>,refresh:array<string,array<string,mixed>>,spent:array<string,array<string,mixed>>,families:array<string,array<string,mixed>>}|null $tokens Vorbereiteter Tokenbestand für einen atomaren Rotationsschreibvorgang.
 * @return array<string,mixed>|WP_Error Erwartet den OAuth-Lock.
 */
function wpagent_companion_oauth_new_pair_locked( array $grant, string $family_id, ?array $tokens = null ) {
	$scope = wpagent_companion_oauth_normalize_scope( $grant['scope'] ?? null );
	if ( is_wp_error( $scope ) ) {
		return new WP_Error( 'invalid_grant', __( 'Die OAuth-Verbindung wurde widerrufen.', 'wp-agent-companion' ) );
	}
	$grant['scope'] = $scope;
	$offline_access = wpagent_companion_oauth_scope_contains( $scope, 'offline_access' );
	$family_id      = $offline_access ? ( wp_is_uuid( $family_id ) ? $family_id : wp_generate_uuid4() ) : '';
	$access        = 'wpa_' . wpagent_companion_oauth_random();
	$access_hash   = wpagent_companion_oauth_hash( $access );
	$refresh       = $offline_access ? 'wprf_' . $family_id . '_' . wpagent_companion_oauth_random() : '';
	$refresh_hash  = $offline_access ? wpagent_companion_oauth_hash( $refresh ) : '';
	$family_hash   = $offline_access ? wpagent_companion_oauth_hash( $family_id ) : '';
	$now           = time();
	$base = array(
		'grant_id' => $grant['grant_id'], 'family_id' => $family_id,
		'client_id' => $grant['client_id'], 'user_id' => $grant['user_id'],
		'scope' => $grant['scope'], 'resource' => $grant['resource'],
	);
	$tokens = null === $tokens ? wpagent_companion_oauth_tokens() : $tokens;
	// Ein Grant besitzt immer nur ein aktuelles Access-/Refresh-Paar und einen
	// kompakten Familienzustand. Dadurch ist der neue Rotationspfad O(aktive
	// Grants), unabhängig davon, wie häufig ein Client sein Token erneuert.
	foreach ( array( 'access', 'refresh' ) as $bucket ) {
		foreach ( $tokens[ $bucket ] as $hash => $record ) {
			if (
				(string) $grant['grant_id'] === (string) ( $record['grant_id'] ?? '' )
				|| ( '' !== $family_id && $family_id === (string) ( $record['family_id'] ?? '' ) )
			) {
				unset( $tokens[ $bucket ][ $hash ] );
			}
		}
	}
	foreach ( $tokens['families'] as $hash => $record ) {
		if (
			(string) $grant['grant_id'] === (string) ( $record['grant_id'] ?? '' )
			|| ( '' !== $family_id && $family_id === (string) ( $record['family_id'] ?? '' ) )
		) {
			unset( $tokens['families'][ $hash ] );
		}
	}
	$tokens['access'][ $access_hash ]   = array_merge( $base, array( 'expires_at' => $now + WPAGENT_COMPANION_OAUTH_ACCESS_TTL ) );
	if ( $offline_access ) {
		$tokens['refresh'][ $refresh_hash ] = array_merge( $base, array( 'expires_at' => $now + WPAGENT_COMPANION_OAUTH_REFRESH_TTL ) );
		$tokens['families'][ $family_hash ] = array(
			'grant_id'     => $grant['grant_id'],
			'family_id'    => $family_id,
			'client_id'    => $grant['client_id'],
			'resource'     => $grant['resource'],
			'current_hash' => $refresh_hash,
			'expires_at'   => $now + WPAGENT_COMPANION_OAUTH_REFRESH_TTL,
		);
	}
	$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_TOKENS, $tokens );
	if ( is_wp_error( $stored ) ) {
		return $stored;
	}
	$response = array(
		'access_token' => $access, 'token_type' => 'Bearer',
		'expires_in' => WPAGENT_COMPANION_OAUTH_ACCESS_TTL,
		'scope' => $scope,
	);
	if ( $offline_access ) {
		$response['refresh_token'] = $refresh;
	}
	return $response;
}

/**
 * Stellt einen fehlgeschlagenen Code-Austausch exakt auf seine Snapshots zurück.
 *
 * Token werden zuerst entfernt, damit ein unvollständiger Austausch selbst bei
 * einem weiteren Speicherfehler niemals ein verwendbares Grant-/Token-Paar
 * hinterlässt. Erwartet den OAuth-Lock.
 *
 * @param array<string,array<string,mixed>> $codes
 * @param array<string,array<string,mixed>> $grants
 * @param array{access:array<string,array<string,mixed>>,refresh:array<string,array<string,mixed>>,spent:array<string,array<string,mixed>>,families:array<string,array<string,mixed>>} $tokens
 * @return true|WP_Error
 */
function wpagent_companion_oauth_restore_exchange_locked( array $codes, array $grants, array $tokens ) {
	$failed = false;
	foreach (
		array(
			WPAGENT_COMPANION_OPT_OAUTH_TOKENS => $tokens,
			WPAGENT_COMPANION_OPT_OAUTH_GRANTS => $grants,
			WPAGENT_COMPANION_OPT_OAUTH_CODES  => $codes,
		) as $option => $snapshot
	) {
		$restored = wpagent_companion_oauth_persist( $option, $snapshot );
		if ( is_wp_error( $restored ) ) {
			$failed = true;
		}
	}
	return $failed
		? new WP_Error( 'server_error', __( 'Der OAuth-Status konnte nicht sicher gespeichert werden.', 'wp-agent-companion' ), array( 'status' => 500 ) )
		: true;
}

/** @return WP_Error Gibt bei erfolgreichem Rollback den ursprünglichen Fehler zurück. */
function wpagent_companion_oauth_exchange_failure( WP_Error $error, array $codes, array $grants, array $tokens ): WP_Error {
	$restored = wpagent_companion_oauth_restore_exchange_locked( $codes, $grants, $tokens );
	return is_wp_error( $restored ) ? $restored : $error;
}

/** @return array<string,mixed>|WP_Error */
function wpagent_companion_oauth_exchange_code( array $params ) {
	$code = is_string( $params['code'] ?? null ) ? $params['code'] : '';
	$verifier = is_string( $params['code_verifier'] ?? null ) ? $params['code_verifier'] : '';
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return $lock;
	}
	try {
		$pruned = wpagent_companion_oauth_prune_locked();
		if ( is_wp_error( $pruned ) ) {
			return $pruned;
		}
		$codes  = wpagent_companion_oauth_codes();
		$hash   = wpagent_companion_oauth_hash( $code );
		$record = $codes[ $hash ] ?? null;
		if ( ! is_array( $record ) || absint( $record['expires_at'] ?? 0 ) <= time() ) {
			return new WP_Error( 'invalid_grant', __( 'Der Authorization Code ist ungültig oder abgelaufen.', 'wp-agent-companion' ) );
		}
		$client_id = is_string( $params['client_id'] ?? null ) ? $params['client_id'] : '';
		$redirect_uri = is_string( $params['redirect_uri'] ?? null ) ? $params['redirect_uri'] : '';
		$resource = is_string( $params['resource'] ?? null ) ? untrailingslashit( $params['resource'] ) : '';
		if ( ! hash_equals( (string) $record['client_id'], $client_id ) || ! hash_equals( (string) $record['redirect_uri'], $redirect_uri ) || ! hash_equals( (string) $record['resource'], $resource ) ) {
			return new WP_Error( 'invalid_grant', __( 'Der Authorization Code passt nicht zu diesem Client.', 'wp-agent-companion' ) );
		}
		$client = wpagent_companion_oauth_clients()[ $client_id ] ?? null;
		if ( ! is_array( $client ) || '' !== (string) ( $client['revoked_at'] ?? '' ) || ! wpagent_companion_oauth_client_allows_grant( $client, 'authorization_code' ) || ( wpagent_companion_oauth_scope_contains( (string) ( $record['scope'] ?? '' ), 'offline_access' ) && ! wpagent_companion_oauth_client_allows_grant( $client, 'refresh_token' ) ) ) {
			return new WP_Error( 'invalid_grant', __( 'Der Authorization Code passt nicht zu diesem Client.', 'wp-agent-companion' ) );
		}
		if ( ! preg_match( '/^[A-Za-z0-9._~-]{43,128}$/', $verifier ) || ! hash_equals( (string) $record['code_challenge'], wpagent_companion_oauth_pkce( $verifier ) ) ) {
			return new WP_Error( 'invalid_grant', __( 'Die PKCE-Prüfung ist fehlgeschlagen.', 'wp-agent-companion' ) );
		}
		$user = get_user_by( 'id', absint( $record['user_id'] ?? 0 ) );
		if ( ! $user || ! wpagent_companion_is_safe_connection_user( $user ) || ! wpagent_companion_gate_open() ) {
			return new WP_Error( 'invalid_grant', __( 'Der freigegebene Agent-Nutzer ist nicht mehr verfügbar.', 'wp-agent-companion' ) );
		}
		$limit           = wpagent_companion_oauth_limit();
		$original_codes  = $codes;
		$original_grants = wpagent_companion_oauth_grants();
		$original_tokens = wpagent_companion_oauth_tokens();
		$grants          = wpagent_companion_oauth_make_grant_room_locked( $original_grants, $limit );
		if ( count( $grants ) >= $limit ) {
			return new WP_Error( 'temporarily_unavailable', __( 'Zu viele aktive OAuth-Verbindungen. Widerrufe zuerst eine nicht mehr benötigte Verbindung.', 'wp-agent-companion' ) );
		}
		$grant_id = wp_generate_uuid4();
		$grant = array(
			'grant_id' => $grant_id, 'client_id' => $client_id, 'user_id' => (int) $user->ID,
			'scope' => (string) $record['scope'], 'resource' => (string) $record['resource'],
			'created_at' => gmdate( 'c' ), 'revoked_at' => '',
		);
		// Token zuerst nur als serverseitigen Hash vorbereiten. Der Client erhält
		// das Klartext-Token erst, nachdem Grant und Codeverbrauch ebenfalls
		// sicher gespeichert wurden. Ohne Grant ist der Zwischenstand unbrauchbar.
		$pair = wpagent_companion_oauth_new_pair_locked( $grant, wp_generate_uuid4(), $original_tokens );
		if ( is_wp_error( $pair ) ) {
			return wpagent_companion_oauth_exchange_failure( $pair, $original_codes, $original_grants, $original_tokens );
		}
		$grants[ $grant_id ] = $grant;
		$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_GRANTS, $grants );
		if ( is_wp_error( $stored ) ) {
			return wpagent_companion_oauth_exchange_failure( $stored, $original_codes, $original_grants, $original_tokens );
		}
		// Der einmalige Code wird zuletzt verbraucht. Scheitert dieser letzte
		// Schritt, werden Grant und Token exakt auf ihre Snapshots zurückgesetzt.
		unset( $codes[ $hash ] );
		$consumed = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_CODES, $codes );
		if ( is_wp_error( $consumed ) ) {
			return wpagent_companion_oauth_exchange_failure( $consumed, $original_codes, $original_grants, $original_tokens );
		}
		return $pair;
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

/** @return array<string,mixed>|WP_Error */
function wpagent_companion_oauth_refresh( array $params ) {
	$raw = is_string( $params['refresh_token'] ?? null ) ? $params['refresh_token'] : '';
	$client_id = is_string( $params['client_id'] ?? null ) ? $params['client_id'] : '';
	$resource = is_string( $params['resource'] ?? null ) ? untrailingslashit( $params['resource'] ) : '';
	$requested_scope = null;
	if ( array_key_exists( 'scope', $params ) ) {
		$requested_scope = wpagent_companion_oauth_normalize_scope( $params['scope'] );
		if ( is_wp_error( $requested_scope ) ) {
			return $requested_scope;
		}
	}
	$hash = wpagent_companion_oauth_hash( $raw );
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return $lock;
	}
	try {
		$tokens = wpagent_companion_oauth_prune_tokens( wpagent_companion_oauth_tokens(), time() );
		$client = wpagent_companion_oauth_clients()[ $client_id ] ?? null;
		if ( ! is_array( $client ) || '' !== (string) ( $client['revoked_at'] ?? '' ) || ! wpagent_companion_oauth_client_allows_grant( $client, 'refresh_token' ) ) {
			return new WP_Error( 'invalid_grant', __( 'Die OAuth-Verbindung wurde widerrufen.', 'wp-agent-companion' ) );
		}
		$family = null;
		if ( preg_match( '/^wprf_([0-9a-f-]{36})_[A-Za-z0-9_-]{43}$/', $raw, $matches ) && wp_is_uuid( $matches[1] ) ) {
			$family = $tokens['families'][ wpagent_companion_oauth_hash( $matches[1] ) ] ?? null;
			if (
				is_array( $family )
				&& hash_equals( (string) ( $family['client_id'] ?? '' ), $client_id )
				&& hash_equals( (string) ( $family['resource'] ?? '' ), $resource )
				&& ! hash_equals( (string) ( $family['current_hash'] ?? '' ), $hash )
			) {
				$revoked = wpagent_companion_oauth_revoke_family_locked( (string) $family['grant_id'], (string) $family['family_id'] );
				if ( is_wp_error( $revoked ) ) {
					return $revoked;
				}
				return new WP_Error( 'invalid_grant', __( 'Wiederverwendung eines Refresh-Tokens erkannt. Die Verbindung wurde widerrufen.', 'wp-agent-companion' ) );
			}
		}
		if ( isset( $tokens['spent'][ $hash ] ) ) {
			$spent = $tokens['spent'][ $hash ];
			$revoked = wpagent_companion_oauth_revoke_family_locked( (string) $spent['grant_id'], (string) $spent['family_id'] );
			if ( is_wp_error( $revoked ) ) {
				return $revoked;
			}
			return new WP_Error( 'invalid_grant', __( 'Wiederverwendung eines Refresh-Tokens erkannt. Die Verbindung wurde widerrufen.', 'wp-agent-companion' ) );
		}
		$record = $tokens['refresh'][ $hash ] ?? null;
		if ( ! is_array( $record ) || absint( $record['expires_at'] ?? 0 ) <= time() || ! hash_equals( (string) ( $record['client_id'] ?? '' ), $client_id ) || ! hash_equals( (string) ( $record['resource'] ?? '' ), $resource ) ) {
			return new WP_Error( 'invalid_grant', __( 'Das Refresh-Token ist ungültig oder abgelaufen.', 'wp-agent-companion' ) );
		}
		$grant = wpagent_companion_oauth_grants()[ (string) $record['grant_id'] ] ?? null;
		if ( ! is_array( $grant ) || '' !== (string) ( $grant['revoked_at'] ?? '' ) ) {
			return new WP_Error( 'invalid_grant', __( 'Die OAuth-Verbindung wurde widerrufen.', 'wp-agent-companion' ) );
		}
		$current_scope = wpagent_companion_oauth_normalize_scope( (string) ( $record['scope'] ?? '' ) );
		$grant_scope   = wpagent_companion_oauth_normalize_scope( (string) ( $grant['scope'] ?? '' ) );
		if (
			is_wp_error( $current_scope )
			|| is_wp_error( $grant_scope )
			|| ! hash_equals( $grant_scope, $current_scope )
			|| ! wpagent_companion_oauth_scope_contains( $current_scope, 'offline_access' )
		) {
			return new WP_Error( 'invalid_grant', __( 'Das Refresh-Token ist ungültig oder abgelaufen.', 'wp-agent-companion' ) );
		}
		$user = get_user_by( 'id', absint( $grant['user_id'] ?? 0 ) );
		if ( ! $user || ! wpagent_companion_is_safe_connection_user( $user ) || ! wpagent_companion_gate_open() ) {
			return new WP_Error( 'invalid_grant', __( 'Die OAuth-Verbindung wurde widerrufen.', 'wp-agent-companion' ) );
		}
		$next_scope = is_string( $requested_scope ) ? $requested_scope : $current_scope;
		foreach ( preg_split( '/\s+/', $next_scope ) ?: array() as $scope_value ) {
			if ( ! wpagent_companion_oauth_scope_contains( $current_scope, $scope_value ) ) {
				return new WP_Error( 'invalid_scope', __( 'Nicht unterstützter OAuth-Scope.', 'wp-agent-companion' ) );
			}
		}
		unset( $tokens['refresh'][ $hash ] );
		foreach ( $tokens['access'] as $access_hash => $access_record ) {
			if ( (string) $record['grant_id'] === (string) ( $access_record['grant_id'] ?? '' ) ) {
				unset( $tokens['access'][ $access_hash ] );
			}
		}
		$is_compact_family = is_array( $family )
			&& hash_equals( (string) ( $family['current_hash'] ?? '' ), $hash )
			&& hash_equals( (string) ( $family['grant_id'] ?? '' ), (string) ( $record['grant_id'] ?? '' ) )
			&& hash_equals( (string) ( $family['family_id'] ?? '' ), (string) ( $record['family_id'] ?? '' ) );
		if ( ! $is_compact_family ) {
			// Legacy-Tokens aus früheren Companion-Versionen besitzen keine im
			// Token kodierte Familien-ID. Ihre Hashes bleiben bis zum Ablauf im
			// bisherigen spent-Bucket, damit Upgrades den Replay-Schutz behalten.
			$tokens['spent'][ $hash ] = array(
				'grant_id' => $record['grant_id'], 'family_id' => $record['family_id'],
				'expires_at' => absint( $record['expires_at'] ),
			);
		}
		$next_grant = array_merge(
			$grant,
			array(
				'scope'    => $next_scope,
				'resource' => (string) $record['resource'],
			)
		);
		// Das verbrauchte Token, sein Access-Token und das neue Paar werden in
		// einem einzigen Options-Schreibvorgang ersetzt. Damit entfällt der
		// frühere persistierte Zwischenzustand ohne gültiges Refresh-Token.
		return wpagent_companion_oauth_new_pair_locked( $next_grant, (string) $record['family_id'], $tokens );
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

function wpagent_companion_oauth_token( WP_REST_Request $request ): WP_REST_Response {
	$params = $request->get_params();
	$grant_type = is_string( $params['grant_type'] ?? null ) ? $params['grant_type'] : '';
	$value = 'authorization_code' === $grant_type
		? wpagent_companion_oauth_exchange_code( $params )
		: ( 'refresh_token' === $grant_type
			? wpagent_companion_oauth_refresh( $params )
			: ( WPAGENT_COMPANION_OAUTH_DEVICE_GRANT === $grant_type
				? wpagent_companion_oauth_exchange_device_code( $params )
				: new WP_Error( 'unsupported_grant_type', __( 'Nicht unterstützter OAuth-Grant.', 'wp-agent-companion' ) ) ) );
	if ( is_wp_error( $value ) ) {
		$status = (int) ( is_array( $value->get_error_data() ) ? ( $value->get_error_data()['status'] ?? 400 ) : 400 );
		return wpagent_companion_oauth_error_response( $value->get_error_code(), $value->get_error_message(), $status );
	}
	$response = new WP_REST_Response( $value, 200 );
	$response->header( 'Cache-Control', 'no-store' );
	$response->header( 'Pragma', 'no-cache' );
	return $response;
}

function wpagent_companion_oauth_revoke( WP_REST_Request $request ): WP_REST_Response {
	$params = $request->get_params();
	$raw = is_string( $params['token'] ?? null ) ? $params['token'] : '';
	$client_id = is_string( $params['client_id'] ?? null ) ? $params['client_id'] : '';
	$hash = wpagent_companion_oauth_hash( $raw );
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( ! is_wp_error( $lock ) ) {
		try {
			$tokens = wpagent_companion_oauth_tokens();
			$record = $tokens['access'][ $hash ] ?? ( $tokens['refresh'][ $hash ] ?? null );
			if ( is_array( $record ) && hash_equals( (string) ( $record['client_id'] ?? '' ), $client_id ) ) {
				wpagent_companion_oauth_revoke_family_locked( (string) $record['grant_id'], (string) $record['family_id'] );
			}
		} finally {
			wpagent_companion_oauth_release_lock( $lock );
		}
	}
	return new WP_REST_Response( null, 200 );
}

function wpagent_companion_oauth_register_routes(): void {
	register_rest_route( 'wp-agent/oauth', '/register', array( 'methods' => 'POST', 'callback' => 'wpagent_companion_oauth_register_client', 'permission_callback' => '__return_true' ) );
	register_rest_route( 'wp-agent/oauth', '/device_authorization', array( 'methods' => 'POST', 'callback' => 'wpagent_companion_oauth_device_authorization', 'permission_callback' => '__return_true' ) );
	register_rest_route( 'wp-agent/oauth', '/token', array( 'methods' => 'POST', 'callback' => 'wpagent_companion_oauth_token', 'permission_callback' => '__return_true' ) );
	register_rest_route( 'wp-agent/oauth', '/revoke', array( 'methods' => 'POST', 'callback' => 'wpagent_companion_oauth_revoke', 'permission_callback' => '__return_true' ) );
	register_rest_route(
		'wp-agent/oauth',
		'/protected-resource',
		array(
			'methods'             => 'GET',
			'callback'            => static function ( WP_REST_Request $request ): WP_REST_Response {
				$resource = 'rest' === $request->get_param( 'resource' ) ? wpagent_companion_oauth_rest_resource() : wpagent_companion_oauth_mcp_resource();
				return new WP_REST_Response( wpagent_companion_oauth_protected_resource_metadata( $resource ), 200 );
			},
			'permission_callback' => '__return_true',
		)
	);
	register_rest_route( 'wp-agent/oauth', '/authorization-server', array( 'methods' => 'GET', 'callback' => static fn() => new WP_REST_Response( wpagent_companion_oauth_server_metadata(), 200 ), 'permission_callback' => '__return_true' ) );
}
add_action( 'rest_api_init', 'wpagent_companion_oauth_register_routes' );

function wpagent_companion_oauth_request_resource(): string {
	if ( isset( $_GET['rest_route'] ) && '/wp-agent/mcp' === untrailingslashit( (string) wp_unslash( $_GET['rest_route'] ) ) ) {
		return wpagent_companion_oauth_mcp_resource();
	}
	$request_path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';
	$home_path = untrailingslashit( (string) wp_parse_url( home_url(), PHP_URL_PATH ) );
	$rest_path = untrailingslashit( $home_path . '/' . trim( rest_get_url_prefix(), '/' ) );
	$normalized_request = untrailingslashit( $request_path );
	if ( $normalized_request === $rest_path . '/wp-agent/mcp' ) {
		return wpagent_companion_oauth_mcp_resource();
	}
	if ( isset( $_GET['rest_route'] ) || $normalized_request === $rest_path || str_starts_with( $normalized_request . '/', $rest_path . '/' ) ) {
		return wpagent_companion_oauth_rest_resource();
	}
	return '';
}

function wpagent_companion_oauth_bearer(): string {
	$header = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? (string) $_SERVER['HTTP_AUTHORIZATION'] : ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ? (string) $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] : '' );
	return preg_match( '/^Bearer ([A-Za-z0-9_-]{32,260})$/', trim( $header ), $matches ) ? $matches[1] : '';
}

function wpagent_companion_oauth_determine_current_user( $user_id ) {
	$resource = wpagent_companion_oauth_request_resource();
	if ( $user_id || '' === $resource ) {
		return $user_id;
	}
	$bearer = wpagent_companion_oauth_bearer();
	if ( '' === $bearer ) {
		return $user_id;
	}
	$record = wpagent_companion_oauth_access_token( $bearer, $resource );
	if ( is_wp_error( $record ) ) {
		return 0;
	}
	$user = get_user_by( 'id', absint( $record['user_id'] ?? 0 ) );
	return $user && wpagent_companion_is_safe_connection_user( $user ) && wpagent_companion_gate_open() ? (int) $user->ID : 0;
}
add_filter( 'determine_current_user', 'wpagent_companion_oauth_determine_current_user', 19 );

function wpagent_companion_oauth_authenticate_header( $response, $server, WP_REST_Request $request ) {
	if ( ! $response instanceof WP_HTTP_Response || 401 !== $response->get_status() ) {
		return $response;
	}
	$is_mcp = '/wp-agent/mcp' === untrailingslashit( $request->get_route() );
	$metadata_url = add_query_arg( 'resource', $is_mcp ? 'mcp' : 'rest', rest_url( 'wp-agent/oauth/protected-resource' ) );
	$response->header( 'WWW-Authenticate', 'Bearer resource_metadata="' . esc_url_raw( $metadata_url ) . '", scope="wpagently"' );
	return $response;
}
add_filter( 'rest_post_dispatch', 'wpagent_companion_oauth_authenticate_header', 10, 3 );

/** @return array<string,mixed>|null RFC-8414/9728-Payload für einen Request-Pfad. */
function wpagent_companion_oauth_well_known_payload( string $request_uri ): ?array {
	$path          = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) );
	$home_path     = untrailingslashit( (string) wp_parse_url( home_url(), PHP_URL_PATH ) );
	$resource_path = untrailingslashit( (string) wp_parse_url( wpagent_companion_oauth_mcp_resource(), PHP_URL_PATH ) );
	$rest_path     = untrailingslashit( (string) wp_parse_url( wpagent_companion_oauth_rest_resource(), PHP_URL_PATH ) );
	$authorization_paths = array(
		'/.well-known/oauth-authorization-server' . $home_path,
		$home_path . '/.well-known/oauth-authorization-server',
	);
	$protected_paths = array(
		'/.well-known/oauth-protected-resource' . $resource_path,
		$home_path . '/.well-known/oauth-protected-resource',
	);
	$rest_protected_paths = array(
		'/.well-known/oauth-protected-resource' . $rest_path,
		$home_path . '/.well-known/oauth-protected-resource/' . trim( rest_get_url_prefix(), '/' ),
	);
	if ( in_array( $path, array_unique( array_map( 'untrailingslashit', $rest_protected_paths ) ), true ) ) {
		return wpagent_companion_oauth_protected_resource_metadata( wpagent_companion_oauth_rest_resource() );
	}
	if ( in_array( $path, array_unique( array_map( 'untrailingslashit', $protected_paths ) ), true ) ) {
		return wpagent_companion_oauth_protected_resource_metadata();
	}
	if ( in_array( $path, array_unique( array_map( 'untrailingslashit', $authorization_paths ) ), true ) ) {
		return wpagent_companion_oauth_server_metadata();
	}
	return null;
}

/** Liefert RFC-8414/9728-Metadaten auch an den normativen Well-known-Pfaden. */
function wpagent_companion_oauth_well_known(): void {
	$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
	$data        = wpagent_companion_oauth_well_known_payload( $request_uri );
	if ( null === $data ) {
		return;
	}
	nocache_headers();
	status_header( 200 );
	header( 'Content-Type: application/json; charset=utf-8' );
	header( 'X-Content-Type-Options: nosniff' );
	echo wp_json_encode( $data, JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-Protokollantwort.
	exit;
}
add_action( 'template_redirect', 'wpagent_companion_oauth_well_known', 0 );
