<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.

namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Registration;

use InvalidArgumentException;
use Yoast\WP\SEO\Exceptions\Locking\Lock_Timeout_Exception;
use Yoast\WP\SEO\Helpers\Lock_Helper;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Rate_Limited_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Not_Found_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Temporarily_Unavailable_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type;
use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Key_Pair_Manager;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http\HTTP_Client;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Discovery_Client;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
use YoastSEO_Vendor\Psr\Log\NullLogger;

/**
 * Manages the full OAuth client registration lifecycle (RFC 7591 + RFC 7592).
 *
 * Handles initial Dynamic Client Registration, credential storage, key rotation,
 * registration verification, and deregistration. Each WordPress site (or subsite
 * in multisite) registers its own client_id. Uses lazy registration with a
 * database-backed exclusive lock.
 */
class Client_Registration implements Client_Registration_Interface, LoggerAwareInterface {
	use LoggerAwareTrait;

	private const OPTION_KEY_PREFIX       = 'wpseo_myyoast_client_registration_';
	private const ENCRYPTION_CONTEXT      = 'yoast-myyoast-registration-credentials';
	private const DCR_LOCK_TTL_IN_SECONDS = \MINUTE_IN_SECONDS;

	/**
	 * The discovery client.
	 *
	 * @var Discovery_Client
	 */
	private $discovery_client;

	/**
	 * The key pair manager.
	 *
	 * @var Key_Pair_Manager
	 */
	private $key_pair_manager;

	/**
	 * The encryption service.
	 *
	 * @var Encryption
	 */
	private $encryption;

	/**
	 * The issuer configuration.
	 *
	 * @var Issuer_Config
	 */
	private $issuer_config;

	/**
	 * The lock helper.
	 *
	 * @var Lock_Helper
	 */
	private $lock_helper;

	/**
	 * The HTTP client.
	 *
	 * @var HTTP_Client
	 */
	private $http_client;

	/**
	 * In-memory cache for registered clients, keyed by option key (avoids repeated decryption within a single request).
	 *
	 * Each entry is Registered_Client|null (null = checked but not registered).
	 * Absence of a key means not yet loaded.
	 *
	 * @var array<string, Registered_Client|null>
	 */
	private $cached_registered_clients = [];

	/**
	 * Client_Registration constructor.
	 *
	 * @param Discovery_Client $discovery_client The discovery client.
	 * @param Key_Pair_Manager $key_pair_manager The key pair manager.
	 * @param Encryption       $encryption       The encryption service.
	 * @param Issuer_Config    $issuer_config    The issuer configuration.
	 * @param Lock_Helper      $lock_helper      The lock helper.
	 * @param HTTP_Client      $http_client      The HTTP client.
	 */
	public function __construct(
		Discovery_Client $discovery_client,
		Key_Pair_Manager $key_pair_manager,
		Encryption $encryption,
		Issuer_Config $issuer_config,
		Lock_Helper $lock_helper,
		HTTP_Client $http_client
	) {
		$this->discovery_client = $discovery_client;
		$this->key_pair_manager = $key_pair_manager;
		$this->encryption       = $encryption;
		$this->issuer_config    = $issuer_config;
		$this->lock_helper      = $lock_helper;
		$this->http_client      = $http_client;
		$this->logger           = new NullLogger();
	}

	/**
	 * Registers the plugin as an OAuth client via DCR (RFC 7591).
	 *
	 * Uses a database-backed exclusive lock to prevent concurrent registrations.
	 *
	 * @param string[] $redirect_uris The OAuth redirect URIs to register.
	 *
	 * @return Registered_Client The registration result.
	 *
	 * @throws Registration_Failed_Exception If registration fails.
	 */
	public function register( array $redirect_uris ): Registered_Client {
		// Acquire lock and execute registration.
		try {
			return $this->lock_helper->execute(
				'wpseo_myyoast_dcr_lock:' . $this->issuer_config->get_issuer_key() . ':' . \get_current_blog_id(),
				function () use ( $redirect_uris ) {
					return $this->do_register( $redirect_uris );
				},
				self::DCR_LOCK_TTL_IN_SECONDS,
			);
		} catch ( Lock_Timeout_Exception $e ) {
			$this->logger->warning( 'DCR lock contention: another registration is already in progress.' );
			throw new Registration_Failed_Exception( 'Another registration is already in progress.' );
		}
	}

	/**
	 * Returns the stored registered client, or null if not registered.
	 *
	 * @return Registered_Client|null The registered client, or null if not registered.
	 */
	public function get_registered_client(): ?Registered_Client {
		$option_key = $this->get_option_key();

		if ( \array_key_exists( $option_key, $this->cached_registered_clients ) ) {
			return $this->cached_registered_clients[ $option_key ];
		}

		$stored = \get_option( $option_key, false );
		if ( ! \is_array( $stored ) || empty( $stored['client_id'] ) || empty( $stored['encrypted_rat'] ) ) {
			$this->cached_registered_clients[ $option_key ] = null;
			return null;
		}

		try {
			$rat = $this->encryption->decrypt( $stored['encrypted_rat'], self::ENCRYPTION_CONTEXT );
		} catch ( Encryption_Exception $e ) {
			$this->logger->error( 'Failed to decrypt registration access token, clearing registration: {error}', [ 'error' => $e->getMessage() ] );
			$this->forget_registration();
			return null;
		}

		try {
			$this->cached_registered_clients[ $option_key ] = new Registered_Client(
				$stored['client_id'],
				$rat,
				( $stored['registration_client_uri'] ?? '' ),
				( $stored['metadata'] ?? [] ),
				( $stored['validated_uris'] ?? [] ),
			);
		} catch ( InvalidArgumentException $e ) {
			$this->logger->error( 'Stored registration data is invalid, clearing registration: {error}', [ 'error' => $e->getMessage() ] );
			$this->forget_registration();
			return null;
		}

		return $this->cached_registered_clients[ $option_key ];
	}

	/**
	 * Ensures the registration's redirect URIs exactly match the given set.
	 *
	 * Performs DCR when not yet registered; when registered with a different set, updates the
	 * registration in place via RFC 7592 (preserving the client_id, RAT, and key pair, and the
	 * verification state of unchanged URIs) rather than re-registering.
	 *
	 * @param string[] $redirect_uris The exact set of OAuth redirect URIs the registration should have.
	 *
	 * @return Registered_Client The client credentials.
	 *
	 * @throws Registration_Failed_Exception If registration fails.
	 */
	public function ensure_registered( array $redirect_uris ): Registered_Client {
		$registered_client = $this->get_registered_client();

		if ( $registered_client !== null && $registered_client->has_redirect_uris( $redirect_uris ) ) {
			return $registered_client;
		}

		if ( $redirect_uris === [] ) {
			throw new Registration_Failed_Exception( 'At least one redirect URI is required for initial registration.' );
		}

		// Registered, but the redirect-URI set differs — update in place (RFC 7592 PUT) so the
		// client_id survives and unchanged URIs keep their verification state and tokens.
		if ( $registered_client !== null ) {
			return $this->update_redirect_uris( $redirect_uris );
		}

		return $this->register( $redirect_uris );
	}

	/**
	 * Reads the current client registration from the server (RFC 7592 GET).
	 *
	 * @return array<string, string|string[]> The registration metadata.
	 *
	 * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404).
	 * @throws Rate_Limited_Exception           If the server rate-limited the request (HTTP 429).
	 * @throws Registration_Failed_Exception    If the read fails for any other reason.
	 */
	public function read_registration(): array {
		$registered_client = $this->get_registered_client();
		if ( $registered_client === null ) {
			throw new Registration_Failed_Exception( 'Not registered.' );
		}

		$result = $this->http_client->authenticated_request(
			'GET',
			$registered_client->get_registration_client_uri(),
			$registered_client->get_registration_access_token(),
			Auth_Token_Type::BEARER,
			[
				'timeout' => 10,
				'headers' => [ 'Accept' => 'application/json' ],
			],
		);

		if ( $result->is_transport_failure() ) {
			$error_message = (string) $result->get_body_value( 'error_description', '' );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
			throw new Registration_Failed_Exception( 'Failed to read registration: ' . $error_message );
		}

		if ( $result->get_status() === 401 || $result->get_status() === 404 ) {
			$this->logger->warning( 'Registration is no longer valid (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] );
			$this->forget_registration();
			throw new Registration_Not_Found_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				'Registration is no longer valid (HTTP ' . $result->get_status() . ').',
			);
		}

		if ( $result->get_status() === 429 ) {
			$this->logger->warning( 'Registration read was rate-limited (HTTP 429).' );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
			throw new Rate_Limited_Exception( 'Registration read was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) );
		}

		if ( ! $result->is_successful() ) {
			$error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
			throw new Registration_Failed_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				\sprintf( 'Registration read returned HTTP %d: %s', $result->get_status(), $error_message ),
			);
		}

		$body = $result->get_body();
		if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
			throw new Registration_Failed_Exception( 'Invalid response from registration endpoint.' );
		}

		// The server is authoritative: heal local data that has drifted from it (for example when a
		// site migration rewrote the stored redirect URIs directly in the database, bypassing the
		// registration round-trip). The GET body carries no RAT, so store_credentials preserves the
		// stored one.
		$this->store_credentials( $body );

		return $body;
	}

	/**
	 * Rotates the registration key pair by updating the registration with a new JWKS (RFC 7592 PUT).
	 *
	 * @return Registered_Client The updated credentials (with new RAT).
	 *
	 * @throws Registration_Failed_Exception If the rotation fails.
	 */
	public function rotate_registration_keys(): Registered_Client {
		$registered_client = $this->get_registered_client();
		if ( $registered_client === null ) {
			throw new Registration_Failed_Exception( 'Not registered.' );
		}

		// Generate a new key pair in memory — only persist after server confirms.
		$new_key_pair = $this->key_pair_manager->generate_key_pair();
		$new_jwk      = $this->key_pair_manager->get_public_key_jwk( $new_key_pair );

		// Build the update request body from stored metadata with the new JWKS.
		// Per RFC 7592 §2.2, server-assigned fields MUST NOT be included.
		$request_body                       = $this->build_update_request_body( $registered_client->get_metadata() );
		$request_body['jwks']               = [ 'keys' => [ $new_jwk ] ];
		$request_body['software_statement'] = $this->issuer_config->get_software_statement();

		// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
		$json = \wp_json_encode( $request_body );
		if ( $json === false ) {
			throw new Registration_Failed_Exception( 'Failed to JSON-encode registration request body.' );
		}

		$result = $this->http_client->authenticated_request(
			'PUT',
			$registered_client->get_registration_client_uri(),
			$registered_client->get_registration_access_token(),
			Auth_Token_Type::BEARER,
			[
				'headers' => [
					'Content-Type' => 'application/json',
					'Accept'       => 'application/json',
				],
				'body'    => $json,
				'timeout' => 15,
			],
		);

		if ( ! $result->is_successful() ) {
			$error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
			throw new Registration_Failed_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				\sprintf( 'Key rotation returned HTTP %d: %s', $result->get_status(), $error_message ),
			);
		}

		$body = $result->get_body();
		if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
			throw new Registration_Failed_Exception( 'Key rotation returned invalid response.' );
		}

		// Server confirmed — now persist the new key pair locally.
		$this->key_pair_manager->store_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION, $new_key_pair );

		// Store the new RAT atomically.
		return $this->store_credentials( $body );
	}

	/**
	 * Updates the registered redirect URIs in place (RFC 7592 PUT).
	 *
	 * Preserves the client_id, registration access token, and key pair. Verification state for
	 * URIs that remain in the set is preserved; URIs no longer present are dropped.
	 *
	 * @param string[] $redirect_uris The new exact set of redirect URIs.
	 *
	 * @return Registered_Client The updated credentials.
	 *
	 * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404).
	 * @throws Rate_Limited_Exception           If the server rate-limited the request (HTTP 429).
	 * @throws Registration_Failed_Exception    If the update fails for any other reason.
	 */
	private function update_redirect_uris( array $redirect_uris ): Registered_Client {
		$registered_client = $this->get_registered_client();
		if ( $registered_client === null ) {
			throw new Registration_Failed_Exception( 'Not registered.' );
		}

		// Per RFC 7592 §2.2, server-assigned fields MUST NOT be included; keep the existing key pair.
		$request_body                       = $this->build_update_request_body( $registered_client->get_metadata() );
		$request_body['redirect_uris']      = \array_values( $redirect_uris );
		$request_body['software_statement'] = $this->issuer_config->get_software_statement();

		// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
		$json = \wp_json_encode( $request_body );
		if ( $json === false ) {
			throw new Registration_Failed_Exception( 'Failed to JSON-encode registration request body.' );
		}

		$result = $this->http_client->authenticated_request(
			'PUT',
			$registered_client->get_registration_client_uri(),
			$registered_client->get_registration_access_token(),
			Auth_Token_Type::BEARER,
			[
				'headers' => [
					'Content-Type' => 'application/json',
					'Accept'       => 'application/json',
				],
				'body'    => $json,
				'timeout' => 15,
			],
		);

		if ( $result->get_status() === 401 || $result->get_status() === 404 ) {
			$this->logger->warning( 'Registration is no longer valid on update (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] );
			$this->forget_registration();
			throw new Registration_Not_Found_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				'Registration is no longer valid (HTTP ' . $result->get_status() . ').',
			);
		}

		if ( $result->get_status() === 429 ) {
			$this->logger->warning( 'Registration update was rate-limited (HTTP 429).' );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
			throw new Rate_Limited_Exception( 'Registration update was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) );
		}

		if ( ! $result->is_successful() ) {
			$error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
			throw new Registration_Failed_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				\sprintf( 'Redirect URI update returned HTTP %d: %s', $result->get_status(), $error_message ),
			);
		}

		$body = $result->get_body();
		if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
			throw new Registration_Failed_Exception( 'Redirect URI update returned invalid response.' );
		}

		return $this->store_credentials( $body );
	}

	/**
	 * Deletes the client registration from the server (RFC 7592 DELETE) and clears local data.
	 *
	 * @return bool True if deleted or already not registered, false on network failure.
	 */
	public function deregister(): bool {
		$credentials = $this->get_registered_client();
		if ( $credentials === null ) {
			return true;
		}

		$result = $this->http_client->authenticated_request(
			'DELETE',
			$credentials->get_registration_client_uri(),
			$credentials->get_registration_access_token(),
			Auth_Token_Type::BEARER,
			[ 'timeout' => 10 ],
		);

		$this->forget_registration();

		return ! $result->is_transport_failure();
	}

	/**
	 * Deletes the stored registration credentials.
	 *
	 * @return void
	 */
	public function forget_registration(): void {
		unset( $this->cached_registered_clients[ $this->get_option_key() ] );
		\delete_option( $this->get_option_key() );
	}

	/**
	 * Deletes all local registration data (credentials, key pairs, caches).
	 *
	 * @return void
	 */
	public function delete_local_data(): void {
		$this->forget_registration();
		$this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION );
		$this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
		$this->discovery_client->invalidate_cache();
		$suffix = $this->issuer_config->get_issuer_key();
		\delete_transient( 'wpseo_myyoast_jwks_' . $suffix );
		\delete_transient( 'wpseo_myyoast_dpop_nonce_' . $suffix );
	}

	/**
	 * Rotates the DPoP key pair (local only, no server coordination).
	 *
	 * @return void
	 */
	public function rotate_dpop_keys(): void {
		$this->key_pair_manager->rotate_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
	}

	/**
	 * Whether the given redirect URI has completed the OAuth authorization-code flow on this site.
	 *
	 * The state lives on the stored registration: it is pruned to the current redirect-URI set
	 * whenever those change, and invalidated when the client is deregistered.
	 *
	 * @param string $redirect_uri The redirect URI to check.
	 *
	 * @return bool
	 */
	public function is_uri_validated( string $redirect_uri ): bool {
		$registered_client = $this->get_registered_client();

		return $registered_client !== null && $registered_client->is_uri_validated( $redirect_uri );
	}

	/**
	 * Records that the given redirect URI has completed the authorization-code flow.
	 *
	 * No-op when the site is not registered or the URI was already recorded. Idempotent:
	 * `update_option()` short-circuits when the stored value is unchanged.
	 *
	 * @param string $redirect_uri The redirect URI that completed the auth-code flow.
	 *
	 * @return void
	 */
	public function mark_uri_validated( string $redirect_uri ): void {
		$registered_client = $this->get_registered_client();
		if ( $registered_client === null ) {
			return;
		}

		$validated_uris = $registered_client->get_validated_uris();
		if ( \in_array( $redirect_uri, $validated_uris, true ) ) {
			return;
		}

		$validated_uris[] = $redirect_uri;

		$option_key = $this->get_option_key();
		$stored     = \get_option( $option_key, [] );
		if ( \is_array( $stored ) ) {
			$stored['validated_uris'] = $validated_uris;
			\update_option( $option_key, $stored, false );
		}

		$this->cached_registered_clients[ $option_key ] = $registered_client->with_validated_uris( $validated_uris );
	}

	/**
	 * Stores the DCR response credentials securely.
	 *
	 * A registration read (RFC 7592 GET) response carries no registration access token; when the
	 * body omits the RAT, the existing stored RAT is preserved rather than overwritten with an
	 * empty value.
	 *
	 * @param array<string, string|array<string>> $response_body The parsed DCR response body.
	 *
	 * @return Registered_Client The stored credentials.
	 */
	private function store_credentials( array $response_body ): Registered_Client {
		$option_key = $this->get_option_key();
		$existing   = $this->get_registered_client();

		// The RFC 7592 GET response never re-sends the RAT, so a missing key means "keep the stored
		// one" — encrypting the absent value would brick every future management call. Only a body
		// that explicitly carries a RAT (DCR / PUT) replaces it.
		if ( \array_key_exists( 'registration_access_token', $response_body ) ) {
			$rat           = $response_body['registration_access_token'];
			$encrypted_rat = $this->encryption->encrypt( $rat, self::ENCRYPTION_CONTEXT );
		}
		else {
			// Reuse the already-decrypted RAT and its stored ciphertext rather than re-encrypting.
			$rat           = ( $existing !== null ) ? $existing->get_registration_access_token() : '';
			$stored        = \get_option( $option_key, [] );
			$encrypted_rat = ( \is_array( $stored ) ) ? ( $stored['encrypted_rat'] ?? '' ) : '';
		}

		// Strip the RAT from metadata — it is stored encrypted separately.
		$metadata = $response_body;
		unset( $metadata['registration_access_token'] );

		// Preserve validation state across an in-place update or key rotation (same client_id), but
		// reset it for a fresh registration: a new client_id means the redirect URIs must be
		// re-validated from scratch. Always prune to the new redirect-URI set so a removed URI loses
		// its verification and an added one starts unverified.
		$validated_uris = [];
		if ( $existing !== null && $existing->get_client_id() === $response_body['client_id'] ) {
			$new_redirect_uris = ( $metadata['redirect_uris'] ?? [] );
			if ( \is_array( $new_redirect_uris ) ) {
				$validated_uris = \array_values( \array_intersect( $existing->get_validated_uris(), $new_redirect_uris ) );
			}
		}

		\update_option(
			$option_key,
			[
				'client_id'               => $response_body['client_id'],
				'encrypted_rat'           => $encrypted_rat,
				'registration_client_uri' => ( $response_body['registration_client_uri'] ?? '' ),
				'metadata'                => $metadata,
				'validated_uris'          => $validated_uris,
			],
			false,
		);

		$this->cached_registered_clients[ $option_key ] = new Registered_Client(
			$response_body['client_id'],
			$rat,
			( $response_body['registration_client_uri'] ?? '' ),
			$metadata,
			$validated_uris,
		);

		return $this->cached_registered_clients[ $option_key ];
	}

	/**
	 * Returns the issuer-scoped option key for storing registration data.
	 *
	 * @return string The option key.
	 */
	private function get_option_key(): string {
		return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key();
	}

	/**
	 * Extracts the `Retry-After` value (in seconds) from a 429 response, if any.
	 *
	 * @param HTTP_Response $result The 429 response.
	 *
	 * @return int|null Seconds until retry, or null when absent or unparseable.
	 */
	private function get_retry_after_seconds( HTTP_Response $result ): ?int {
		$headers = $result->get_headers();
		return Rate_Limited_Exception::parse_retry_after( ( $headers['retry-after'] ?? null ) );
	}

	/**
	 * Performs the actual DCR registration request.
	 *
	 * @param string[] $redirect_uris The OAuth redirect URIs to register.
	 *
	 * @return Registered_Client The registration result.
	 *
	 * @throws Registration_Temporarily_Unavailable_Exception If the server temporarily refuses new registrations (HTTP 503).
	 * @throws Rate_Limited_Exception                          If the server rate-limited the request (HTTP 429).
	 * @throws Registration_Failed_Exception                   If registration fails for any other reason.
	 */
	private function do_register( array $redirect_uris ): Registered_Client {
		try {
			$registration_endpoint = $this->discovery_client->get_document()->get_registration_endpoint();
		} catch ( Discovery_Failed_Exception | Server_Capability_Exception $e ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
			throw new Registration_Failed_Exception( 'OIDC discovery failed: ' . $e->getMessage(), 0, $e );
		}

		$software_statement   = $this->issuer_config->get_software_statement();
		$initial_access_token = $this->issuer_config->get_initial_access_token();

		if ( $software_statement === '' || $initial_access_token === '' ) {
			throw new Registration_Failed_Exception( 'Software statement and initial access token must be configured.' );
		}

		// Ensure a registration key pair exists.
		$key_pair   = $this->key_pair_manager->get_or_create_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION );
		$public_jwk = $this->key_pair_manager->get_public_key_jwk( $key_pair );

		$request_body = [
			'software_statement'         => $software_statement,
			'redirect_uris'              => $redirect_uris,
			'grant_types'                => [ 'authorization_code', 'refresh_token', 'client_credentials' ],
			'token_endpoint_auth_method' => 'private_key_jwt',
			'jwks'                       => [ 'keys' => [ $public_jwk ] ],
			'dpop_bound_access_tokens'   => true,
		];

		// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
		$json = \wp_json_encode( $request_body );
		if ( $json === false ) {
			throw new Registration_Failed_Exception( 'Failed to JSON-encode DCR request body.' );
		}

		$result = $this->http_client->request(
			'POST',
			$registration_endpoint,
			[
				'headers' => [
					'Authorization' => 'Bearer ' . $initial_access_token,
					'Content-Type'  => 'application/json',
					'Accept'        => 'application/json',
				],
				'body'    => $json,
				'timeout' => 15,
			],
		);

		if ( $result->is_transport_failure() ) {
			$error_message = (string) $result->get_body_value( 'error_description', '' );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
			throw new Registration_Failed_Exception( 'DCR request failed: ' . $error_message );
		}

		// The server temporarily refuses new registrations (rollout brake engaged).
		// Surface it as a typed transient failure carrying the (display-only) retry hint.
		if ( $result->get_status() === 503 && $result->get_body_value( 'error' ) === 'temporarily_unavailable' ) {
			$error_message = (string) $result->get_body_value( 'error_description', 'Client registration is temporarily disabled.' );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
			throw new Registration_Temporarily_Unavailable_Exception( $error_message, $this->get_retry_after_seconds( $result ) );
		}

		if ( $result->get_status() === 429 ) {
			$this->logger->warning( 'DCR was rate-limited (HTTP 429).' );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
			throw new Rate_Limited_Exception( 'DCR was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) );
		}

		if ( $result->get_status() !== 201 ) {
			$error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
			throw new Registration_Failed_Exception(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
				\sprintf( 'DCR returned HTTP %d: %s', $result->get_status(), $error_message ),
			);
		}

		$body = $result->get_body();
		if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
			throw new Registration_Failed_Exception( 'DCR returned invalid response.' );
		}

		return $this->store_credentials( $body );
	}

	/**
	 * Strips server-assigned fields from metadata for a RFC 7592 PUT request.
	 *
	 * Per RFC 7592 §2.2, the update request body MUST NOT include fields
	 * that are assigned by the server (e.g. registration_client_uri,
	 * client_id_issued_at, client_secret, client_secret_expires_at).
	 * The software_statement is also stripped since a fresh one is provided.
	 *
	 * phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- OAuth metadata is an associative array with heterogeneous values.
	 *
	 * @param array<string, mixed> $metadata The stored client metadata.
	 *
	 * @return array<string, mixed> The metadata suitable for a PUT request body.
	 *
	 * phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint
	 */
	private function build_update_request_body( array $metadata ): array {
		unset(
			$metadata['registration_access_token'],
			$metadata['registration_client_uri'],
			$metadata['client_id_issued_at'],
			$metadata['client_secret'],
			$metadata['client_secret_expires_at'],
			$metadata['software_statement'],
		);

		return $metadata;
	}
}
