<?php
/**
 * Burst Statistics – MainWP Proxy (Child Site)
 *
 * Exposes a single REST endpoint (`burst/v1/mainwp-auth`) that the MainWP
 * dashboard calls during a full page load.  It:
 *
 *   1. Verifies the asymmetric signature sent by the dashboard.
 *   2. Resolves the admin user named in the request.
 *   3. Issues (or reuses) a WP Application Password token for that user.
 *   4. Returns the token, a REST nonce, user capabilities, and any
 *      localization data the dashboard React app needs.
 *
 * ── Security model ────────────────────────────────────────────────────────────
 * The dashboard holds an RSA private key; the child stores the matching public
 * key in `mainwp_child_pubkey`.  Every request is signed over the payload
 * `$function|$nonce|$user` where $nonce is a cryptographically random 32-char
 * hex string generated by the dashboard on each call.
 *
 * Nonces are consumed server-side on first use (via add_option atomicity) and
 * cleaned up after NONCE_TTL_SECONDS.  A second request with the same nonce is
 * rejected even if the signature is still mathematically valid.
 * ─────────────────────────────────────────────────────────────────────────────
 *
 * @package Burst\Frontend
 */

namespace Burst\Frontend;

use Burst\Traits\Helper;
use Burst\Traits\Sanitize;
use Burst\Traits\Admin_Helper;
use WP_REST_Response;
use WP_REST_Server;

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

class MainWP_Proxy {
	use Helper;
	use Sanitize;
	use Admin_Helper;

	/**
	 * Transient key prefix used to persist the Application Password token.
	 * Storing the token avoids creating a new Application Password on every page load.
	 */
	private const APP_TOKEN_META_KEY = 'burst_mainwp_app_token';

	/**
	 * Display name given to the Application Password created for this integration.
	 */
	private const APP_PASSWORD_NAME = 'Burst MainWP';

	/**
	 * Option key prefix used to record consumed nonces.
	 * Format: burst_mainwp_nonce_{32-char-hex}
	 *
	 * Each option stores the Unix timestamp at which the nonce was consumed.
	 * A scheduled event purges the entry after NONCE_TTL_SECONDS + 60s.
	 */
	private const NONCE_OPTION_PREFIX = 'burst_mainwp_nonce_';

	/**
	 * In-process cache of nonces verified during this request.
	 *
	 * WordPress can call permission callbacks and other hooks multiple times
	 * within a single PHP process (e.g. check_auth_permission + is_mainwp_signed_request
	 * both calling verify_signature on the same request body). The first call
	 * consumes the nonce via add_option(); subsequent calls for the same nonce
	 * within the same process are allowed through via this cache rather than
	 * being rejected as replays.
	 *
	 * This cache is intentionally not shared across processes or requests —
	 * it lives only for the lifetime of the current PHP process.
	 *
	 * @var array<string, bool>
	 */
	private array $consumed_nonces = [];

	/**
	 * Acceptance window for nonces, in seconds.
	 *
	 * A nonce that arrives more than this many seconds after it was issued is
	 * rejected regardless of cryptographic validity.  The dashboard embeds the
	 * issue timestamp alongside the nonce so the child can enforce this window.
	 *
	 * 5 minutes is generous for a server-to-server call.
	 */
	private const NONCE_TTL_SECONDS = 300;

	/**
	 * Register REST routes.  Call from a service-container or direct `init()`.
	 */
	public function init(): void {
		$this->send_cors_headers();
		add_action( 'rest_api_init', [ $this, 'register_routes' ] );
		add_action( 'burst_purge_mainwp_nonce', [ $this, 'purge_nonce_option' ] );
	}

	/**
	 * Register the `burst/v1/mainwp-auth` endpoint.
	 *
	 * The permission callback accepts either a valid MainWP-signed body (the
	 * server-to-server / unauthenticated bootstrap path) or a logged-in user
	 * with the Burst management capability. Subscribers and unauthenticated
	 * unsigned callers are rejected before the route callback runs.
	 */
	public function register_routes(): void {
		register_rest_route(
			'burst/v1',
			'/mainwp-auth',
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'handle_auth_request' ],
				'permission_callback' => [ $this, 'check_auth_permission' ],
			]
		);
	}

	/**
	 * Permission gate for the mainwp-auth endpoint.
	 *
	 * Three callers are legitimate, each with its own CSRF defense:
	 *  - MainWP dashboard bootstrap: sends a signed body containing a single-use
	 *    nonce, identified by the X-BURSTMAINWP header. The signature itself is
	 *    the CSRF defense — a browser cannot forge it.
	 *  - Application Password / HTTP Basic Auth: no session cookie is involved,
	 *    so CSRF is structurally impossible.
	 *  - Cookie-authenticated admin (e.g. a same-origin call from the Burst UI):
	 *    must present a valid `burst_nonce` to prove explicit user intent and
	 *    prevent a logged-in admin from being tricked into minting an
	 *    Application Password via a forged cross-origin request.
	 *
	 * @param \WP_REST_Request|null $request The REST request (provided by WP).
	 */
	public function check_auth_permission( ?\WP_REST_Request $request = null ): bool {
		if ( ! current_user_can( 'manage_burst_statistics' ) ) {
			return false;
		}

		if ( $this->is_mainwp_request_context() ) {
			return true;
		}

		// Cookie-auth path: require an explicit burst_nonce.
		$nonce = '';
		if ( $request instanceof \WP_REST_Request ) {
			$header_nonce = $request->get_header( 'x_burst_nonce' );
			if ( is_string( $header_nonce ) && $header_nonce !== '' ) {
				$nonce = $header_nonce;
			} else {
				$param_nonce = $request->get_param( 'burst_nonce' );
				if ( is_string( $param_nonce ) ) {
					$nonce = $param_nonce;
				}
			}
		}

		return $this->verify_nonce( $nonce, 'burst_nonce' );
	}

	/**
	 * Whether the current request was authenticated as a MainWP dashboard call.
	 *
	 * Matches the two server-to-server paths that already have CSRF protection
	 * baked in:
	 *  - X-BURSTMAINWP header set to '1' (only sent by the dashboard, and a
	 *    custom header forces a CORS preflight that a cross-origin browser
	 *    request cannot pass without an explicit allow).
	 *  - HTTP Basic Authorization that resolved to an Application Password
	 *    during this request.
	 */
	private function is_mainwp_request_context(): bool {
		if ( isset( $_SERVER['HTTP_X_BURSTMAINWP'] ) && $_SERVER['HTTP_X_BURSTMAINWP'] === '1' ) {
			return true;
		}

		if ( self::is_http_basic_auth_request() && (bool) did_action( 'application_password_did_authenticate' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Send CORS headers to allow the MainWP dashboard to make cross-origin requests.
	 *
	 * The dashboard's origin is dynamic and must be reflected back in the
	 * `Access-Control-Allow-Origin` header.  We also allow credentials and
	 * necessary headers for the dashboard's REST requests.
	 *
	 * If the request method is OPTIONS, we return a 204 immediately after
	 * sending the headers, without invoking any REST route callbacks, since
	 * this is a preflight request.
	 */
	private function send_cors_headers(): void {
		if ( isset( $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ) && ! str_contains( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ) ), 'x-burstmainwp' ) ) {
			return;
		}

		$origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? sanitize_url( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : '';
		if ( empty( $origin ) ) {
			return;
		}

		if ( empty( get_option( 'mainwp_child_pubkey' ) ) ) {
			return;
		}

		// Only reflect known dashboard origins once pairing has completed.
		$allowed_origin = $this->get_allowed_dashboard_origin();
		$request_origin = $this->normalize_origin( $origin );
		$origin_matches = $allowed_origin !== '' && $request_origin !== '' && hash_equals( $allowed_origin, $request_origin );

		if ( $allowed_origin === '' ) {
			// Unpaired bootstrap: only the mainwp-auth endpoint may negotiate
			// CORS, and credentials are NOT permitted — the first call is
			// signed and must not depend on a logged-in cookie.
			if ( ! $this->is_mainwp_auth_endpoint() ) {
				return;
			}
		} elseif ( ! $origin_matches ) {
			return;
		}

		header_remove( 'Access-Control-Allow-Origin' );
		header_remove( 'Access-Control-Allow-Headers' );
		header_remove( 'Access-Control-Allow-Methods' );
		header_remove( 'Access-Control-Allow-Credentials' );

		header( 'Access-Control-Allow-Origin: ' . $origin );
		if ( $origin_matches ) {
			header( 'Access-Control-Allow-Credentials: true' );
		}
		header( 'Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS' );
		header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce, X-Burst-Auth-Cookie, X-Requested-With, X-Burst-Share-Token, X-BurstMainWP' );
		header( 'Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link' );
		header( 'Vary: Origin' );

		if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
			status_header( 204 );
			exit;
		}
	}

	/**
	 * Normalized dashboard origin stored from the last verified signed request.
	 *
	 * Returns an empty string when no dashboard has paired yet.
	 */
	private function get_allowed_dashboard_origin(): string {
		$stored = (string) get_option( 'burst_mainwp_dashboard_url', '' );
		return $stored === '' ? '' : $this->normalize_origin( $stored );
	}

	/**
	 * Reduce a URL to scheme://host[:port] for stable origin comparison.
	 */
	private function normalize_origin( string $url ): string {
		$parts = wp_parse_url( $url );
		if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return '';
		}

		$origin = strtolower( $parts['scheme'] . '://' . $parts['host'] );
		if ( ! empty( $parts['port'] ) ) {
			$origin .= ':' . (int) $parts['port'];
		}

		return $origin;
	}

	/**
	 * Whether this request targets the mainwp-auth bootstrap endpoint.
	 *
	 * Supports both pretty permalinks and the `?rest_route=` fallback.
	 */
	private function is_mainwp_auth_endpoint(): bool {
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		if ( $request_uri !== '' && str_contains( $request_uri, '/burst/v1/mainwp-auth' ) ) {
			return true;
		}

		$query = wp_parse_url( $request_uri, PHP_URL_QUERY );
		if ( ! is_string( $query ) || $query === '' ) {
			return false;
		}

		$query_params = [];
		parse_str( $query, $query_params );

		$rest_route = isset( $query_params['rest_route'] ) ? sanitize_text_field( (string) $query_params['rest_route'] ) : '';
		return $rest_route === '/burst/v1/mainwp-auth';
	}

	/**
	 * Handle an incoming auth request from the MainWP dashboard.
	 *
	 * Authentication, capability check, user-switching, and origin persistence
	 * all happen in `check_auth_permission()`. By the time we get here the
	 * current user is the resolved admin and we only need to mint (or reuse)
	 * the short-lived Application Password.
	 *
	 * @return WP_REST_Response 200 on success; 500 on token creation failure.
	 */
	public function handle_auth_request(): WP_REST_Response {
		$user  = wp_get_current_user();
		$token = $this->get_or_create_app_token( $user->ID );
		if ( is_wp_error( $token ) ) {
			return new WP_REST_Response(
				[
					'error'      => $token->get_error_message(),
					'error_code' => $token->get_error_code(),
					'user_id'    => (int) $user->ID,
				],
				500
			);
		}

		return new WP_REST_Response(
			[
				'token'             => $token,
				'root_url'          => get_rest_url( null, '/' ),
				'localization_data' => $this->localized_settings( [ 'json_translations' => '' ] ),
			],
			200
		);
	}

	/**
	 * Determine if the incoming request is a valid, signed MainWP request.
	 *
	 * Used as a gatekeeper in admin screens shared between regular WP users
	 * and the MainWP dashboard, to conditionally show/hide sensitive data.
	 *
	 * @return bool True when the request carries a valid MainWP signature.
	 */
	public function is_mainwp_signed_request(): bool {
		$request_body = file_get_contents( 'php://input' );
		$body         = json_decode( $request_body, true );

		if ( empty( $body ) || ! is_array( $body ) ) {
			return false;
		}

		if ( ! $this->verify_signature( $body ) ) {
			return false;
		}

		$username = sanitize_user( $body['user'] ?? '' );
		$user     = get_user_by( 'login', $username );

		if ( ! $user || ! user_can( $user, 'manage_burst_statistics' ) ) {
			return false;
		}

		wp_set_current_user( $user->ID );

		// Persist the dashboard origin for CORS reflection on subsequent
		// browser requests from the React app. The dashboard calls this
		// endpoint server-to-server (no browser Origin header is present),
		// so the body field is the only available source. This is safe
		// because we only reach this branch after successful signature
		// verification: only the holder of the MainWP private key can
		// supply a valid body.
		$body_origin       = is_string( $body['dashboard_origin'] ?? null ) ? sanitize_url( $body['dashboard_origin'] ) : '';
		$normalized_origin = $this->normalize_origin( $body_origin );
		if ( $normalized_origin !== '' ) {
			update_option( 'burst_mainwp_dashboard_url', $normalized_origin );
		}

		return true;
	}

	/**
	 * Determine if the current request is authenticated as the MainWP dashboard.
	 *
	 * Used in REST route permission callbacks to restrict access to routes that
	 * should only be reachable via the MainWP Application Password credential.
	 *
	 * @return bool True when the request is authenticated as MainWP.
	 */
	public function is_mainwp_authenticated(): bool {
		$auth_header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '' ) );

		if ( empty( $auth_header ) || stripos( $auth_header, 'basic ' ) !== 0 ) {
			return false;
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		$credentials = base64_decode( substr( $auth_header, 6 ), true );
		if ( ! $credentials ) {
			return false;
		}

		$parts = explode( ':', $credentials, 2 );
		if ( count( $parts ) !== 2 ) {
			return false;
		}

		if ( ! function_exists( 'wp_validate_application_password' ) ) {
			return false;
		}

		// Save and restore the original PHP_AUTH_* server values so that populating
		// them here (required by wp_validate_application_password) does not bleed into
		// any other auth checks that may run later in the same request.
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$saved_auth_user = $_SERVER['PHP_AUTH_USER'] ?? null;
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$saved_auth_pw = $_SERVER['PHP_AUTH_PW'] ?? null;

		// We are *writing* to $_SERVER so wp_validate_application_password() can read the
		// credentials. $parts values originate from the sanitized $auth_header above.
		// Sanitizing further (e.g. sanitize_text_field) would corrupt the password.
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$_SERVER['PHP_AUTH_USER'] = $parts[0];
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$_SERVER['PHP_AUTH_PW'] = $parts[1];

		// wp_validate_application_password() only operates when WordPress considers the
		// request to be a REST API context. This function is called from has_admin_access(),
		// which runs before REST routes are loaded — it is the gate that decides whether to
		// load them. At that early point WordPress has not yet identified the request as an
		// API context, so without this filter wp_validate_application_password() returns
		// nothing and the credential check silently fails, resulting in a 404 "route not found"
		// because the REST endpoint files are never loaded. The filter is scoped tightly and
		// removed immediately after the call.
		add_filter( 'application_password_is_api_request', '__return_true', 99 );
		$validated_user_id = wp_validate_application_password( false );
		remove_filter( 'application_password_is_api_request', '__return_true', 99 );

		if ( $saved_auth_user !== null ) {
			$_SERVER['PHP_AUTH_USER'] = $saved_auth_user;
		} else {
			unset( $_SERVER['PHP_AUTH_USER'] );
		}
		if ( $saved_auth_pw !== null ) {
			$_SERVER['PHP_AUTH_PW'] = $saved_auth_pw;
		} else {
			unset( $_SERVER['PHP_AUTH_PW'] );
		}

		if ( empty( $validated_user_id ) ) {
			return false;
		}

		// wp_validate_application_password() validates both the username and the
		// application password credential atomically and returns the authenticated user's
		// ID. We use that ID directly — no secondary username comparison is needed or
		// safe, because the validated ID already proves the credentials matched.
		$authenticated_user = get_user_by( 'id', (int) $validated_user_id );
		if ( ! $authenticated_user instanceof \WP_User ) {
			return false;
		}

		if ( ! user_can( $authenticated_user, 'manage_burst_statistics' ) ) {
			return false;
		}

		wp_set_current_user( $authenticated_user->ID );
		return true;
	}

	// ── Nonce Management ──────────────────────────────────────────────────────

	/**
	 * Consume a nonce atomically, rejecting it if already used or expired.
	 *
	 * The body must include a `nonce_issued_at` Unix timestamp set by the
	 * dashboard at signing time.  We reject nonces issued more than
	 * NONCE_TTL_SECONDS ago so a captured body cannot be replayed after the
	 * window closes even if it was never consumed.
	 *
	 * `add_option()` returns false when the key already exists, giving us a
	 * cheap atomic compare-and-set without a DB transaction.
	 *
	 * @param string $nonce     The 32-char hex nonce from the signed body.
	 * @param int    $issued_at Unix timestamp embedded in the body by the dashboard.
	 * @return bool True if the nonce was freshly consumed; false otherwise.
	 */
	private function consume_nonce( string $nonce, int $issued_at ): bool {
		// If this nonce was already consumed earlier in the same PHP process
		// (e.g. check_auth_permission and is_mainwp_signed_request both firing
		// on the same request body), allow it through without hitting the DB again.
		if ( isset( $this->consumed_nonces[ $nonce ] ) ) {
			return true;
		}

		// Reject nonces that are outside the acceptance window.
		$age = time() - $issued_at;
		if ( $age < 0 || $age > self::NONCE_TTL_SECONDS ) {
			self::error_log( sprintf( 'MainWP nonce rejected: age %ds outside TTL window.', $age ) );
			return false;
		}

		// Validate the nonce looks like a 32-char lowercase hex string.
		if ( ! preg_match( '/^[0-9a-f]{32}$/', $nonce ) ) {
			return false;
		}

		$option_key = self::NONCE_OPTION_PREFIX . $nonce;

		// add_option() is a no-op (returns false) when the key already exists —
		// this is our atomic single-use enforcement across processes.
		$consumed = add_option( $option_key, time(), '', false );

		if ( $consumed ) {
			// Record in-process so subsequent same-request calls don't false-positive.
			$this->consumed_nonces[ $nonce ] = true;

			// Schedule cleanup — give a little extra headroom beyond the TTL.
			wp_schedule_single_event(
				time() + self::NONCE_TTL_SECONDS + 60,
				'burst_purge_mainwp_nonce',
				[ $option_key ]
			);
		}

		return $consumed;
	}

	/**
	 * Delete a single consumed nonce option.
	 *
	 * Hooked to the `burst_purge_mainwp_nonce` scheduled event.
	 *
	 * @param string $option_key The option key to delete.
	 */
	public function purge_nonce_option( string $option_key ): void {
		if ( str_starts_with( $option_key, self::NONCE_OPTION_PREFIX ) ) {
			delete_option( $option_key );
		}
	}

	// ── Signature Verification ────────────────────────────────────────────────

	/**
	 * Verify the asymmetric signature attached to a dashboard request.
	 *
	 * Expected signed payload: `$function|$nonce|$user`
	 *
	 * The dashboard generates `$nonce` as `bin2hex(random_bytes(16))` — a
	 * 32-char cryptographically random hex string — and embeds `nonce_issued_at`
	 * (Unix timestamp) alongside it.  Both are covered by the nonce-consumption
	 * check to prevent replay attacks.
	 *
	 * `verifylib` == 1 → phpseclib path (SHA-1 + PKCS#1 v1.5).
	 * `verifylib` == 0 → standard OpenSSL (SHA-256).
	 *
	 * @param array $body Decoded JSON body from the dashboard.
	 * @return bool True when the signature is valid and the nonce is fresh.
	 */
	private function verify_signature( array $body ): bool {
		$pubkey_stored = get_option( 'mainwp_child_pubkey' );
		$signature_b64 = $body['mainwpsignature'] ?? '';
		$nonce         = is_string( $body['nonce'] ?? null ) ? $body['nonce'] : '';
		$issued_at     = isset( $body['nonce_issued_at'] ) ? (int) $body['nonce_issued_at'] : 0;
		$function      = $body['function'] ?? '';
		$user_raw      = is_string( $body['user'] ?? null ) ? $body['user'] : '';
		$use_seclib    = ! empty( $body['verifylib'] );

		if ( empty( $pubkey_stored ) || empty( $signature_b64 ) || $nonce === '' || $issued_at === 0 || empty( $function ) || $user_raw === '' ) {
			return false;
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		$pubkey_pem = base64_decode( $pubkey_stored, true );
		if ( empty( $pubkey_pem ) || ! str_contains( $pubkey_pem, '-----BEGIN' ) ) {
			self::error_log( 'Pubkey decode failed or value is not a PEM block.' );
			return false;
		}

		$public_key = openssl_pkey_get_public( $pubkey_pem );
		if ( false === $public_key ) {
			self::error_log( 'OpenSSL could not parse the stored MainWP public key.' );
			return false;
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		$signature = base64_decode( $signature_b64, true );
		if ( false === $signature ) {
			return false;
		}

		$algorithm    = $use_seclib ? OPENSSL_ALGO_SHA1 : OPENSSL_ALGO_SHA256;
		$sign_payload = $function . '|' . $nonce . '|' . $user_raw;
		$result       = openssl_verify( $sign_payload, $signature, $public_key, $algorithm );

		if ( 1 !== $result ) {
			$msg = openssl_error_string();
			while ( $msg ) {
				self::error_log( 'OpenSSL verification error: ' . $msg );
				$msg = openssl_error_string();
			}
			return false;
		}

		// Signature is cryptographically valid — enforce single-use and TTL.
		if ( ! $this->consume_nonce( $nonce, $issued_at ) ) {
			self::error_log( 'MainWP nonce rejected (already consumed or expired): ' . $nonce );
			return false;
		}

		return true;
	}

	// ── Token Management ──────────────────────────────────────────────────────

	/**
	 * Return an existing short-lived Application Password token for this user,
	 * or create a fresh one.
	 *
	 * A transient (1-hour TTL) holds the plain-text token so it is never stored
	 * long-term.  When the transient expires the old Application Password is
	 * deleted and a new one is minted on the next request.
	 *
	 * @param int $user_id WP user ID.
	 * @return string|\WP_Error Base64 `user:password` token, or WP_Error on failure.
	 */
	private function get_or_create_app_token( int $user_id ): string|\WP_Error {
		if ( $user_id <= 0 ) {
			return new \WP_Error( 'invalid_mainwp_user', 'Could not resolve MainWP user for app password generation.' );
		}

		$transient_key = self::APP_TOKEN_META_KEY . '_' . $user_id;
		$cached        = get_transient( $transient_key );
		if ( ! empty( $cached ) ) {
			return $cached;
		}

		if ( ! class_exists( 'WP_Application_Passwords' ) ) {
			return new \WP_Error( 'app_passwords_unavailable', 'Application Passwords not available.' );
		}

		// Delete any existing Burst MainWP app passwords to prevent accumulation.
		$existing = \WP_Application_Passwords::get_user_application_passwords( $user_id );
		foreach ( $existing as $app_password ) {
			if ( $app_password['name'] === self::APP_PASSWORD_NAME ) {
				\WP_Application_Passwords::delete_application_password( $user_id, $app_password['uuid'] );
			}
		}

		$result = \WP_Application_Passwords::create_new_application_password(
			$user_id,
			[ 'name' => self::APP_PASSWORD_NAME ]
		);

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$user = get_userdata( $user_id );
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		$token = base64_encode( $user->user_login . ':' . $result[0] );

		// Store for 1 hour only — plain-text token never persisted long-term.
		set_transient( $transient_key, $token, HOUR_IN_SECONDS );

		return $token;
	}
}
