<?php
/**
 * Persistenz und Einmalverwendung für den selbst gehosteten OAuth-Server.
 * Geheimnisse werden ausschließlich als SHA-256-Hash gespeichert.
 *
 * @package WPAgentCompanion
 */

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

define( 'WPAGENT_COMPANION_OPT_OAUTH_CLIENTS', 'wp_agent_companion_oauth_clients' );
define( 'WPAGENT_COMPANION_OPT_OAUTH_CODES', 'wp_agent_companion_oauth_codes' );
define( 'WPAGENT_COMPANION_OPT_OAUTH_TOKENS', 'wp_agent_companion_oauth_tokens' );
define( 'WPAGENT_COMPANION_OPT_OAUTH_GRANTS', 'wp_agent_companion_oauth_grants' );
define( 'WPAGENT_COMPANION_OPT_OAUTH_DEVICE_CODES', 'wp_agent_companion_oauth_device_codes' );
define( 'WPAGENT_COMPANION_OPT_OAUTH_LOCK', 'wp_agent_companion_oauth_lock' );
define( 'WPAGENT_COMPANION_OPT_OAUTH_DCR', 'wp_agent_companion_oauth_dcr' );
define( 'WPAGENT_COMPANION_OPT_OAUTH_DEVICE_RATE', 'wp_agent_companion_oauth_device_rate' );
define( 'WPAGENT_COMPANION_OAUTH_ACCESS_TTL', 900 );
define( 'WPAGENT_COMPANION_OAUTH_REFRESH_TTL', 2592000 );
define( 'WPAGENT_COMPANION_OAUTH_CODE_TTL', 300 );
define( 'WPAGENT_COMPANION_OAUTH_DEVICE_CODE_TTL', 600 );
define( 'WPAGENT_COMPANION_OAUTH_DEVICE_EXPIRED_RETENTION', 300 );
define( 'WPAGENT_COMPANION_OAUTH_DEVICE_INTERVAL', 5 );
define( 'WPAGENT_COMPANION_OAUTH_DEVICE_MAX_INTERVAL', 60 );
define( 'WPAGENT_COMPANION_OAUTH_DEVICE_GRANT', 'urn:ietf:params:oauth:grant-type:device_code' );
define( 'WPAGENT_COMPANION_OAUTH_LIMIT', 200 );
define( 'WPAGENT_COMPANION_OAUTH_PENDING_CLIENT_TTL', HOUR_IN_SECONDS );

/**
 * Liefert die gemeinsame Kapazitätsgrenze für Clients, Codes und Grants.
 *
 * Vertrauenswürdiger Servercode kann das Standardlimit für größere Agenturen
 * anheben. Die harte Obergrenze verhindert versehentlich unbeschränkte
 * Optionswerte und damit übergroße Serialisierungen in jedem OAuth-Aufruf.
 */
function wpagent_companion_oauth_limit(): int {
	/**
	 * Filtert die maximale Zahl gleichzeitig gespeicherter OAuth-Datensätze.
	 *
	 * @param int $limit Standardmäßig 200, erlaubt sind 1 bis 10.000.
	 */
	$filtered = apply_filters( 'wpagent_companion_oauth_max_clients', WPAGENT_COMPANION_OAUTH_LIMIT );
	if ( ! is_int( $filtered ) && ! ( is_string( $filtered ) && ctype_digit( $filtered ) ) ) {
		return WPAGENT_COMPANION_OAUTH_LIMIT;
	}

	return max( 1, min( 10000, (int) $filtered ) );
}

/** @return string Kryptografisch zufälliger Base64url-Wert ohne Padding. */
function wpagent_companion_oauth_random( int $bytes = 32 ): string {
	return rtrim( strtr( base64_encode( random_bytes( $bytes ) ), '+/', '-_' ), '=' );
}

function wpagent_companion_oauth_hash( string $secret ): string {
	return hash( 'sha256', $secret );
}

/** Liefert einen nicht rückrechenbaren, zweckgebundenen Ratelimit-Schlüssel der Quell-IP. */
function wpagent_companion_oauth_public_rate_key( string $purpose ): string {
	$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : 'cli';
	return 'wpagent_oauth_' . $purpose . '_' . substr( hash_hmac( 'sha256', $purpose . "\0" . $ip, wp_salt( 'nonce' ) ), 0, 32 );
}

/** Liefert den nicht rückrechenbaren DCR-Ratelimit-Schlüssel der Quell-IP. */
function wpagent_companion_oauth_registration_rate_key(): string {
	return wpagent_companion_oauth_public_rate_key( 'dcr' );
}

/** Liefert den nicht rückrechenbaren Geräte-Ratelimit-Schlüssel der Quell-IP. */
function wpagent_companion_oauth_device_authorization_rate_key(): string {
	return wpagent_companion_oauth_public_rate_key( 'device' );
}

/** Converts a fail-closed public OAuth storage failure into a retryable response. */
function wpagent_companion_oauth_public_storage_error( WP_Error $error ): WP_Error {
	if ( 'server_error' !== $error->get_error_code() ) {
		return $error;
	}
	return new WP_Error(
		'temporarily_unavailable',
		__( 'Der OAuth-Status wird gerade geändert. Versuche es erneut.', 'wp-agent-companion' ),
		array( 'status' => 503 )
	);
}

/**
 * Verbraucht unter dem OAuth-Lock genau einen öffentlichen OAuth-Slot.
 *
 * Der optionale WP-CLI-Bypass hält lokale Verwaltungs- und Integrationstests
 * frei von HTTP-Ratelimits. Sicherheitstests können ihn bewusst deaktivieren.
 * DCR und Gerätefreigabe besitzen getrennte, jeweils auf 1.024 Einträge
 * begrenzte Optionsmaps. Ein verteilter DCR-Angriff kann damit keine neuen
 * Gerätefreigaben sperren. Beide Writes bleiben trotzdem unter demselben
 * OAuth-Lock atomar gegen Client-, Code- und Tokenzustand serialisiert.
 *
 * @return true|WP_Error
 */
function wpagent_companion_oauth_consume_public_slot_locked( string $purpose, bool $respect_wp_cli = true ) {
	if ( $respect_wp_cli && defined( 'WP_CLI' ) && WP_CLI ) {
		return true;
	}
	if ( ! in_array( $purpose, array( 'dcr', 'device' ), true ) ) {
		return new WP_Error( 'server_error', __( 'Ungültiger OAuth-Ratelimit-Zweck.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	$key     = wpagent_companion_oauth_public_rate_key( $purpose );
	$now     = time();
	$option  = 'device' === $purpose ? WPAGENT_COMPANION_OPT_OAUTH_DEVICE_RATE : WPAGENT_COMPANION_OPT_OAUTH_DCR;
	$stored  = get_option( $option, array() );
	$buckets = is_array( $stored ) ? $stored : array();
	foreach ( $buckets as $bucket_key => $bucket ) {
		if ( ! is_array( $bucket ) || absint( $bucket['expires_at'] ?? 0 ) <= $now ) {
			unset( $buckets[ $bucket_key ] );
		}
	}
	$bucket = is_array( $buckets[ $key ] ?? null ) ? $buckets[ $key ] : array();
	$count  = absint( $bucket['count'] ?? 0 );
	if ( $count >= 20 ) {
		return new WP_Error( 'temporarily_unavailable', __( 'Zu viele OAuth-Anfragen. Versuche es später erneut.', 'wp-agent-companion' ), array( 'status' => 429 ) );
	}
	if ( ! isset( $buckets[ $key ] ) && count( $buckets ) >= 1024 ) {
		return new WP_Error( 'temporarily_unavailable', __( 'Zu viele OAuth-Anfragen. Versuche es später erneut.', 'wp-agent-companion' ), array( 'status' => 429 ) );
	}

	$buckets[ $key ] = array(
		'count'      => $count + 1,
		'expires_at' => $now + HOUR_IN_SECONDS,
	);
	$persisted = wpagent_companion_oauth_persist( $option, $buckets );
	if ( is_wp_error( $persisted ) ) {
		return wpagent_companion_oauth_public_storage_error( $persisted );
	}
	return true;
}

/** @return true|WP_Error */
function wpagent_companion_oauth_consume_registration_slot_locked( bool $respect_wp_cli = true ) {
	return wpagent_companion_oauth_consume_public_slot_locked( 'dcr', $respect_wp_cli );
}

/** @return true|WP_Error */
function wpagent_companion_oauth_consume_device_authorization_slot_locked( bool $respect_wp_cli = true ) {
	return wpagent_companion_oauth_consume_public_slot_locked( 'device', $respect_wp_cli );
}

/**
 * Clears only the public DCR and device-code rate windows.
 *
 * Existing clients, grants, codes and tokens remain untouched. The shared
 * OAuth lock prevents a concurrent public request from reintroducing a stale
 * bucket between deletion and the direct database read-back.
 *
 * @return true|WP_Error
 */
function wpagent_companion_oauth_reset_public_rate_limits() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return new WP_Error( 'wpagent_forbidden', __( 'Keine Berechtigung.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return $lock;
	}
	try {
		foreach ( array( WPAGENT_COMPANION_OPT_OAUTH_DCR, WPAGENT_COMPANION_OPT_OAUTH_DEVICE_RATE ) as $option ) {
			delete_option( $option );
			wpagent_companion_forget_option_cache( $option, false );
			if ( null !== wpagent_companion_read_option_raw_direct( $option ) ) {
				return new WP_Error(
					'wpagent_oauth_rate_reset_failed',
					__( 'Die öffentlichen OAuth-Anfragelimits konnten nicht vollständig zurückgesetzt werden.', 'wp-agent-companion' ),
					array( 'status' => 500 )
				);
			}
		}
		return true;
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

/**
 * Speichert Ratelimit und Client-Pool innerhalb desselben OAuth-Locks.
 *
 * @return true|WP_Error
 */
function wpagent_companion_oauth_store_registration( array $client, bool $respect_wp_cli = true ) {
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return $lock;
	}
	try {
		$allowed = wpagent_companion_oauth_consume_registration_slot_locked( $respect_wp_cli );
		if ( is_wp_error( $allowed ) ) {
			return $allowed;
		}
		$stored = wpagent_companion_oauth_store_client_locked( $client );
		return is_wp_error( $stored ) ? wpagent_companion_oauth_public_storage_error( $stored ) : $stored;
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

/** @return string|WP_Error */
function wpagent_companion_oauth_acquire_lock() {
	$token = wpagent_companion_acquire_option_lock( WPAGENT_COMPANION_OPT_OAUTH_LOCK, 30 );
	if ( is_string( $token ) ) {
		return $token;
	}
	return new WP_Error( 'temporarily_unavailable', __( 'Der OAuth-Status wird gerade geändert. Versuche es erneut.', 'wp-agent-companion' ), array( 'status' => 503 ) );
}

function wpagent_companion_oauth_release_lock( string $token ): void {
	wpagent_companion_release_option_lock( WPAGENT_COMPANION_OPT_OAUTH_LOCK, $token );
}

/** @return array<string,array<string,mixed>> */
function wpagent_companion_oauth_clients(): array {
	$stored = get_option( WPAGENT_COMPANION_OPT_OAUTH_CLIENTS, array() );
	return is_array( $stored ) ? $stored : array();
}

/** @return array<string,array<string,mixed>> */
function wpagent_companion_oauth_codes(): array {
	$stored = get_option( WPAGENT_COMPANION_OPT_OAUTH_CODES, array() );
	return is_array( $stored ) ? $stored : array();
}

/** @return array<string,array<string,mixed>> Device- und User-Codes liegen ausschließlich gehasht vor. */
function wpagent_companion_oauth_device_codes(): array {
	$stored = get_option( WPAGENT_COMPANION_OPT_OAUTH_DEVICE_CODES, array() );
	return is_array( $stored ) ? $stored : array();
}

/** @return 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>>} */
function wpagent_companion_oauth_tokens(): array {
	$stored = get_option( WPAGENT_COMPANION_OPT_OAUTH_TOKENS, array() );
	$stored = is_array( $stored ) ? $stored : array();
	return array(
		'access'   => is_array( $stored['access'] ?? null ) ? $stored['access'] : array(),
		'refresh'  => is_array( $stored['refresh'] ?? null ) ? $stored['refresh'] : array(),
		'spent'    => is_array( $stored['spent'] ?? null ) ? $stored['spent'] : array(),
		'families' => is_array( $stored['families'] ?? null ) ? $stored['families'] : array(),
	);
}

/** @return array<string,array<string,mixed>> */
function wpagent_companion_oauth_grants(): array {
	$stored = get_option( WPAGENT_COMPANION_OPT_OAUTH_GRANTS, array() );
	return is_array( $stored ) ? $stored : array();
}

/** @return array<int,array<string,mixed>> */
function wpagent_companion_oauth_connections(): array {
	$clients = wpagent_companion_oauth_clients();
	$tokens  = wpagent_companion_oauth_tokens();
	$health  = function_exists( 'wpagent_companion_connection_health_state' )
		? wpagent_companion_connection_health_state()
		: array( 'success' => array() );
	$result  = array();
	foreach ( wpagent_companion_oauth_grants() as $grant ) {
		if ( ! is_array( $grant ) || ! wp_is_uuid( (string) ( $grant['grant_id'] ?? '' ) ) ) {
			continue;
		}
		$user   = get_user_by( 'id', absint( $grant['user_id'] ?? 0 ) );
		$client = $clients[ (string) ( $grant['client_id'] ?? '' ) ] ?? array();
		$active = false;
		$last_expires = 0;
		foreach ( array( 'access', 'refresh' ) as $bucket ) {
			foreach ( $tokens[ $bucket ] as $record ) {
				if ( (string) $grant['grant_id'] === (string) ( $record['grant_id'] ?? '' ) && absint( $record['expires_at'] ?? 0 ) > time() ) {
					$active = true;
					$last_expires = max( $last_expires, absint( $record['expires_at'] ) );
				}
			}
		}
		$result[] = array(
			'id'              => (string) $grant['grant_id'],
			'user_id'         => absint( $grant['user_id'] ?? 0 ),
			'user_login'      => $user ? (string) $user->user_login : '',
			'client'          => 'oauth',
			'client_name'     => sanitize_text_field( (string) ( $client['client_name'] ?? 'MCP client' ) ),
			'label'           => sanitize_text_field( (string) ( $client['client_name'] ?? 'OAuth' ) ),
			'created_at'      => sanitize_text_field( (string) ( $grant['created_at'] ?? '' ) ),
			'status'          => $active && '' === (string) ( $grant['revoked_at'] ?? '' ) ? 'active' : 'revoked',
			'revoked_by_user' => '' !== (string) ( $grant['revoked_at'] ?? '' ),
			'last_used'       => isset( $health['success'][ $grant['grant_id'] ] )
				? gmdate( 'c', absint( $health['success'][ $grant['grant_id'] ] ) )
				: '',
			'expires_at'      => $last_expires ? gmdate( 'c', $last_expires ) : '',
			'auth_type'       => 'oauth',
			'resource'        => sanitize_url( (string) ( $grant['resource'] ?? '' ) ),
		);
	}
	return $result;
}

/** @return true|WP_Error */
function wpagent_companion_oauth_persist( string $option, array $value ) {
	update_option( $option, $value, false );
	return get_option( $option, null ) === $value
		? true
		: new WP_Error( 'server_error', __( 'Der OAuth-Status konnte nicht sicher gespeichert werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
}

/**
 * Bereinigt einen bereits geladenen Tokenbestand ohne eigenen Schreibzugriff.
 * So kann die Refresh-Rotation abgelaufene Altlasten im selben atomaren
 * Options-Write wie das neue Tokenpaar entfernen.
 *
 * @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 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>>}
 */
function wpagent_companion_oauth_prune_tokens( array $tokens, int $now ): array {
	foreach ( array( 'access', 'refresh', 'spent' ) as $bucket ) {
		foreach ( $tokens[ $bucket ] as $hash => $record ) {
			if ( ! is_array( $record ) || absint( $record['expires_at'] ?? 0 ) <= $now ) {
				unset( $tokens[ $bucket ][ $hash ] );
			}
		}
	}
	foreach ( $tokens['families'] as $hash => $record ) {
		$current_hash = is_array( $record ) ? (string) ( $record['current_hash'] ?? '' ) : '';
		if (
			! is_string( $hash )
			|| ! preg_match( '/^[a-f0-9]{64}$/', $hash )
			|| ! is_array( $record )
			|| ! wp_is_uuid( (string) ( $record['family_id'] ?? '' ) )
			|| absint( $record['expires_at'] ?? 0 ) <= $now
			|| ! preg_match( '/^[a-f0-9]{64}$/', $current_hash )
			|| ! isset( $tokens['refresh'][ $current_hash ] )
		) {
			unset( $tokens['families'][ $hash ] );
		}
	}
	return $tokens;
}

/** @return true|WP_Error Entfernt abgelaufene Einmalwerte. Erwartet den OAuth-Lock. */
function wpagent_companion_oauth_prune_locked() {
	$now = time();
	$codes = wpagent_companion_oauth_codes();
	foreach ( $codes as $hash => $record ) {
		if ( ! is_array( $record ) || absint( $record['expires_at'] ?? 0 ) <= $now ) {
			unset( $codes[ $hash ] );
		}
	}
	$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_CODES, $codes );
	if ( is_wp_error( $stored ) ) {
		return $stored;
	}

	$devices = wpagent_companion_oauth_device_codes();
	foreach ( $devices as $hash => $record ) {
		if (
			! is_string( $hash )
			|| ! preg_match( '/^[a-f0-9]{64}$/', $hash )
			|| ! is_array( $record )
			|| ! preg_match( '/^[a-f0-9]{64}$/', (string) ( $record['user_code_hash'] ?? '' ) )
			// Den abgelaufenen Hash nur kurz für RFC-8628 expired_token
			// nachhalten. Danach ist er ein wertloser Tombstone und darf die
			// begrenzte Optionsmap nicht für eine weitere Stunde vergrößern.
			|| absint( $record['expires_at'] ?? 0 ) + WPAGENT_COMPANION_OAUTH_DEVICE_EXPIRED_RETENTION <= $now
		) {
			unset( $devices[ $hash ] );
		}
	}
	$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_DEVICE_CODES, $devices );
	if ( is_wp_error( $stored ) ) {
		return $stored;
	}

	$tokens = wpagent_companion_oauth_prune_tokens( wpagent_companion_oauth_tokens(), $now );
	return wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_TOKENS, $tokens );
}

/**
 * Entfernt abgebrochene DCR-Registrierungen, die nie einen Grant erhielten.
 * Erwartet den OAuth-Lock. Registrierungen mit einem Grant bleiben erhalten,
 * auch wenn ihr Token gerade abgelaufen oder widerrufen ist.
 *
 * @param array<string,array<string,mixed>> $clients
 * @return array<string,array<string,mixed>>
 */
function wpagent_companion_oauth_prune_pending_clients_locked( array $clients ): array {
	$referenced = array();
	foreach ( wpagent_companion_oauth_grants() as $grant ) {
		if ( is_array( $grant ) && is_string( $grant['client_id'] ?? null ) ) {
			$referenced[ $grant['client_id'] ] = true;
		}
	}
	$cutoff = time() - WPAGENT_COMPANION_OAUTH_PENDING_CLIENT_TTL;
	foreach ( $clients as $client_id => $client ) {
		if ( isset( $referenced[ $client_id ] ) ) {
			continue;
		}
		$created = is_array( $client ) ? strtotime( (string) ( $client['created_at'] ?? '' ) ) : false;
		if ( false === $created || $created <= $cutoff ) {
			unset( $clients[ $client_id ] );
		}
	}
	return $clients;
}

/**
 * Gibt bei Kapazitätsdruck Registrierungen frei, deren Grants vollständig
 * widerrufen sind oder kein einziges noch gültiges Access- oder Refresh-Token
 * mehr besitzen. Frische, noch nicht autorisierte DCR-Clients bleiben erhalten.
 * Erwartet den OAuth-Lock.
 *
 * @param array<string,array<string,mixed>> $clients
 * @return array<string,array<string,mixed>>
 */
function wpagent_companion_oauth_prune_inactive_clients_locked( array $clients ): array {
	$now         = time();
	$live_grants = array();
	foreach ( array( 'access', 'refresh' ) as $bucket ) {
		foreach ( wpagent_companion_oauth_tokens()[ $bucket ] as $record ) {
			if ( is_array( $record ) && absint( $record['expires_at'] ?? 0 ) > $now ) {
				$grant_id = (string) ( $record['grant_id'] ?? '' );
				if ( '' !== $grant_id ) {
					$live_grants[ $grant_id ] = true;
				}
			}
		}
	}

	$referenced_clients = array();
	$live_clients       = array();
	foreach ( wpagent_companion_oauth_grants() as $grant_id => $grant ) {
		if ( ! is_array( $grant ) ) {
			continue;
		}
		$client_id = (string) ( $grant['client_id'] ?? '' );
		if ( '' === $client_id ) {
			continue;
		}
		$referenced_clients[ $client_id ] = true;
		if ( '' === (string) ( $grant['revoked_at'] ?? '' ) && isset( $live_grants[ (string) $grant_id ] ) ) {
			$live_clients[ $client_id ] = true;
		}
	}

	foreach ( $clients as $client_id => $client ) {
		$revoked = is_array( $client ) && '' !== (string) ( $client['revoked_at'] ?? '' );
		$dead    = isset( $referenced_clients[ $client_id ] ) && ! isset( $live_clients[ $client_id ] );
		if ( $revoked || $dead ) {
			unset( $clients[ $client_id ] );
		}
	}
	return $clients;
}

/**
 * Gibt bei Kapazitätsdruck inaktive Grants frei.
 *
 * Widerrufene, beschädigte und tokenlose Grants sind keine aktiven
 * Verbindungen und dürfen die konfigurierbare Verbindungskapazität nicht
 * dauerhaft belegen. Noch gültige Access- oder Refresh-Tokens schützen einen
 * nicht widerrufenen Grant vor der Bereinigung. Hashes bereits verbrauchter
 * Legacy-Tokens bleiben getrennt bis zu ihrem Ablauf erhalten, damit ihr
 * Replay-Schutz auch nach einem Upgrade funktioniert.
 *
 * Erwartet den OAuth-Lock und einen zuvor durch
 * wpagent_companion_oauth_prune_locked() bereinigten Tokenbestand.
 *
 * @param array<string,array<string,mixed>> $grants
 * @return array<string,array<string,mixed>>
 */
function wpagent_companion_oauth_make_grant_room_locked( array $grants, int $limit ): array {
	if ( count( $grants ) < $limit ) {
		return $grants;
	}

	$now         = time();
	$live_grants = array();
	$tokens      = wpagent_companion_oauth_tokens();
	foreach ( array( 'access', 'refresh' ) as $bucket ) {
		foreach ( $tokens[ $bucket ] as $record ) {
			if ( ! is_array( $record ) || absint( $record['expires_at'] ?? 0 ) <= $now ) {
				continue;
			}
			$grant_id = (string) ( $record['grant_id'] ?? '' );
			if ( '' !== $grant_id ) {
				$live_grants[ $grant_id ] = true;
			}
		}
	}

	foreach ( $grants as $grant_id => $grant ) {
		if (
			! is_array( $grant )
			|| '' !== (string) ( $grant['revoked_at'] ?? '' )
			|| ! isset( $live_grants[ (string) $grant_id ] )
		) {
			unset( $grants[ $grant_id ] );
		}
	}

	return $grants;
}

/** @return true|WP_Error Erwartet den OAuth-Lock. */
function wpagent_companion_oauth_store_client_locked( array $client ) {
	$clients = wpagent_companion_oauth_prune_pending_clients_locked( wpagent_companion_oauth_clients() );
	$limit = wpagent_companion_oauth_limit();
	if ( count( $clients ) >= $limit ) {
		$clients = wpagent_companion_oauth_prune_inactive_clients_locked( $clients );
	}
	if ( count( $clients ) >= $limit ) {
		return new WP_Error( 'invalid_client_metadata', __( 'Zu viele OAuth-Clients. Entferne zuerst eine nicht mehr benötigte Verbindung.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$clients[ $client['client_id'] ] = $client;
	return wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_CLIENTS, $clients );
}

/** @return true|WP_Error */
function wpagent_companion_oauth_store_client( array $client ) {
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return $lock;
	}
	try {
		return wpagent_companion_oauth_store_client_locked( $client );
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

/** @return true|WP_Error Entfernt sämtliche Token einer Grant- oder Tokenfamilie. Erwartet den OAuth-Lock. */
function wpagent_companion_oauth_revoke_family_locked( string $grant_id, string $family_id ) {
	$tokens = wpagent_companion_oauth_tokens();
	foreach ( array( 'access', 'refresh' ) as $bucket ) {
		foreach ( $tokens[ $bucket ] as $hash => $record ) {
			if ( $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 (
			$grant_id === (string) ( $record['grant_id'] ?? '' )
			|| ( '' !== $family_id && $family_id === (string) ( $record['family_id'] ?? '' ) )
		) {
			unset( $tokens['families'][ $hash ] );
		}
	}
	$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_TOKENS, $tokens );
	if ( is_wp_error( $stored ) ) {
		return $stored;
	}

	$grants = wpagent_companion_oauth_grants();
	if ( isset( $grants[ $grant_id ] ) ) {
		$client_id = (string) ( $grants[ $grant_id ]['client_id'] ?? '' );
		$grants[ $grant_id ]['revoked_at'] = gmdate( 'c' );
		$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_GRANTS, $grants );
		if ( is_wp_error( $stored ) ) {
			return $stored;
		}

		$has_active_grant = false;
		foreach ( $grants as $grant ) {
			if ( is_array( $grant ) && $client_id === (string) ( $grant['client_id'] ?? '' ) && '' === (string) ( $grant['revoked_at'] ?? '' ) ) {
				$has_active_grant = true;
				break;
			}
		}
		if ( '' !== $client_id && ! $has_active_grant ) {
			$clients = wpagent_companion_oauth_clients();
			if ( isset( $clients[ $client_id ] ) && is_array( $clients[ $client_id ] ) ) {
				$clients[ $client_id ]['revoked_at'] = gmdate( 'c' );
				$stored = wpagent_companion_oauth_persist( WPAGENT_COMPANION_OPT_OAUTH_CLIENTS, $clients );
				if ( is_wp_error( $stored ) ) {
					return $stored;
				}
			}
		}
	}
	return true;
}

/** @return array{revoked:bool}|WP_Error */
function wpagent_companion_oauth_revoke_grant( string $grant_id ) {
	$lock = wpagent_companion_oauth_acquire_lock();
	if ( is_wp_error( $lock ) ) {
		return $lock;
	}
	try {
		$grants = wpagent_companion_oauth_grants();
		if ( ! isset( $grants[ $grant_id ] ) ) {
			return new WP_Error( 'wpagent_connection_not_found', __( 'Verbindung nicht gefunden.', 'wp-agent-companion' ), array( 'status' => 404 ) );
		}
		$revoked = wpagent_companion_oauth_revoke_family_locked( $grant_id, '' );
		if ( is_wp_error( $revoked ) ) {
			return $revoked;
		}
		return array( 'revoked' => true );
	} finally {
		wpagent_companion_oauth_release_lock( $lock );
	}
}

/** @return array<string,mixed>|WP_Error */
function wpagent_companion_oauth_access_token( string $token, string $resource = '' ) {
	if ( ! preg_match( '/^wpa_[A-Za-z0-9_-]{32,256}$/', $token ) ) {
		return new WP_Error( 'invalid_token', __( 'Ungültiges oder abgelaufenes OAuth-Token.', 'wp-agent-companion' ), array( 'status' => 401 ) );
	}
	$record = wpagent_companion_oauth_tokens()['access'][ wpagent_companion_oauth_hash( $token ) ] ?? null;
	if ( ! is_array( $record ) || absint( $record['expires_at'] ?? 0 ) <= time() ) {
		return new WP_Error( 'invalid_token', __( 'Ungültiges oder abgelaufenes OAuth-Token.', 'wp-agent-companion' ), array( 'status' => 401 ) );
	}
	if ( '' !== $resource && ! hash_equals( untrailingslashit( (string) ( $record['resource'] ?? '' ) ), untrailingslashit( $resource ) ) ) {
		return new WP_Error( 'invalid_token', __( 'Das OAuth-Token ist an eine andere Ressource gebunden.', 'wp-agent-companion' ), array( 'status' => 401 ) );
	}
	if ( ! function_exists( 'wpagent_companion_oauth_scope_contains' ) || ! wpagent_companion_oauth_scope_contains( (string) ( $record['scope'] ?? '' ), 'wpagently' ) ) {
		return new WP_Error( 'invalid_token', __( 'Ungültiges oder abgelaufenes OAuth-Token.', 'wp-agent-companion' ), array( 'status' => 401 ) );
	}
	$grants = wpagent_companion_oauth_grants();
	$grant  = $grants[ (string) ( $record['grant_id'] ?? '' ) ] ?? null;
	$client_id = (string) ( $record['client_id'] ?? '' );
	$client    = wpagent_companion_oauth_clients()[ $client_id ] ?? null;
	if (
		! is_array( $grant )
		|| '' !== (string) ( $grant['revoked_at'] ?? '' )
		|| ! is_array( $client )
		|| '' !== (string) ( $client['revoked_at'] ?? '' )
		|| '' === $client_id
		|| ! hash_equals( $client_id, (string) ( $client['client_id'] ?? '' ) )
		|| ! hash_equals( $client_id, (string) ( $grant['client_id'] ?? '' ) )
		|| ! hash_equals( (string) ( $record['grant_id'] ?? '' ), (string) ( $grant['grant_id'] ?? '' ) )
		|| absint( $record['user_id'] ?? 0 ) !== absint( $grant['user_id'] ?? 0 )
		|| ! hash_equals( (string) ( $record['scope'] ?? '' ), (string) ( $grant['scope'] ?? '' ) )
		|| ! hash_equals( untrailingslashit( (string) ( $record['resource'] ?? '' ) ), untrailingslashit( (string) ( $grant['resource'] ?? '' ) ) )
	) {
		return new WP_Error( 'invalid_token', __( 'Ungültiges oder abgelaufenes OAuth-Token.', 'wp-agent-companion' ), array( 'status' => 401 ) );
	}
	if ( function_exists( 'wpagent_companion_record_oauth_success' ) ) {
		wpagent_companion_record_oauth_success( (string) $record['grant_id'] );
	}
	return $record;
}
