<?php
/**
 * Editor REST service provider.
 *
 * Phase 6 slice 6c-D Bootstrap-layer service provider that wires
 * the §10.1-§10.3 editor-helper REST endpoints documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §10.1 `POST /clb/v1/editor/preview`   → {@see EditorController::preview()}.
 *   - §10.2 `GET  /clb/v1/editor/templates` → {@see EditorController::templates()}.
 *   - §10.3 `GET  /clb/v1/editor/providers` → {@see EditorController::providers()}.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_PREVIEW_USE_CASE}   — §10.1 use case.
 *   - {@see self::BINDING_TEMPLATES_USE_CASE} — §10.2 use case.
 *   - {@see self::BINDING_PROVIDERS_USE_CASE} — §10.3 use case.
 *   - {@see self::BINDING_CONTROLLER}         — composed controller.
 *
 * Provider ordering — register() resolves bindings published by
 * earlier providers, so the bootstrap pipeline in
 * `commerce-link-builder.php` MUST register:
 *
 *   1. {@see \CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider}
 *      (publishes
 *      {@see \CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY}).
 *
 *   2. {@see \CommerceLinkBuilder\Application\Render\RenderServiceProvider}
 *      (publishes
 *      {@see \CommerceLinkBuilder\Application\Render\RenderServiceProvider::BINDING_PRODUCT_CARD_VIEW_MODEL_FACTORY},
 *      {@see \CommerceLinkBuilder\Application\Render\RenderServiceProvider::BINDING_PRODUCT_CARD_RENDERER},
 *      {@see \CommerceLinkBuilder\Application\Render\RenderServiceProvider::BINDING_VIEW_MODEL_ARRAY_DUMPER}).
 *
 *   3. {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider}
 *      (publishes
 *      {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider::BINDING_ROUTE_REGISTRAR}).
 *
 *   4. This provider (resolves all four bindings, appends three
 *      {@see \CommerceLinkBuilder\Presentation\Rest\Routing\RouteDefinition}
 *      values to the registrar so the §6a `rest_api_init` driver
 *      forwards them to WordPress at the canonical registration
 *      moment).
 *
 * Phase-6 minimum behaviors (documented, never silently dropped):
 *
 *   - **Empty provider registry for §10.3.** {@see ListAvailableProvidersUseCase}
 *     is constructed with an empty `array()` registry. Its
 *     constructor explicitly tolerates the empty case so the §10.3
 *     endpoint answers `items: []` while the admin completes
 *     provider setup. Phase 7+ slices rebind the use case with a
 *     concrete provider list (Rakuten in Phase 4 is already
 *     implemented; Amazon / Yahoo come in Phase 7 / 8). Hard-coding
 *     a Phase-4 default here would couple the REST slice to the
 *     Infrastructure provider class, which the §1.3 layering rules
 *     forbid.
 *
 *   - **WP REST permission_callback always returns true.** The
 *     §3.2 / §3.3 capability check runs inside
 *     {@see EditorController::buildContext()} via the injected
 *     {@see CapabilityPolicy} + capability provider so a denied
 *     caller surfaces as the canonical §4.3 `clb_forbidden`
 *     envelope rather than the generic WordPress 401/403 page.
 *     Re-running the same check at the WP REST layer would only
 *     duplicate work and risk diverging from the controller's
 *     envelope contract.
 *
 *   - **Synthetic `_http_status` envelope key extraction.** Each
 *     controller method returns the §4 envelope array PLUS
 *     {@see EditorController::META_HTTP_STATUS}. The route
 *     callback wrapper this provider builds reads the key,
 *     strips it from the array, and stamps the desired HTTP
 *     status onto a {@see \WP_REST_Response} (or — in the
 *     pure-unit test harness where `WP_REST_Response` is not
 *     defined — onto a `array('data' => ..., 'status' => ...)`
 *     shim). The key is therefore never serialized onto the
 *     wire response.
 *
 * Resolution policy:
 *
 *   - Every collaborator is optional; omitting one causes the
 *     provider to construct the production default during
 *     register(). Tests inject synthetic collaborators one axis
 *     at a time via the constructor overrides.
 *
 *   - Resolution is eager at register() time so a misconfigured
 *     bootstrap pipeline (Persistence, Render, or REST provider
 *     missing from the order above) surfaces during
 *     {@see ServiceProviderRegistry::registerAll()} rather than
 *     at the first REST request.
 *
 *   - The composed controller is cached on this provider so the
 *     route-callback closures can capture the same instance
 *     without re-resolving the registry binding.
 *
 * @package CommerceLinkBuilder\Presentation\Rest
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest;

use CommerceLinkBuilder\Application\Editor\UseCase\ListAvailableProvidersUseCase;
use CommerceLinkBuilder\Application\Editor\UseCase\ListAvailableTemplatesUseCase;
use CommerceLinkBuilder\Application\Editor\UseCase\PreviewProductCardUseCase;
use CommerceLinkBuilder\Application\Provider\ProviderRegistry;
use CommerceLinkBuilder\Application\Render\ProductCardRenderer;
use CommerceLinkBuilder\Application\Render\RenderServiceProvider;
use CommerceLinkBuilder\Application\Render\ViewModel\ProductCardViewModelFactory;
use CommerceLinkBuilder\Application\Render\ViewModelArrayDumper;
use CommerceLinkBuilder\Bootstrap\HookRegistrar;
use CommerceLinkBuilder\Bootstrap\ServiceProvider;
use CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry;
use CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRepository;
use CommerceLinkBuilder\Presentation\Rest\Controller\EditorController;
use CommerceLinkBuilder\Presentation\Rest\Controller\RestRequestAdapter;
use CommerceLinkBuilder\Presentation\Rest\Permission\CapabilityPolicy;
use CommerceLinkBuilder\Presentation\Rest\Response\ProviderExceptionMapper;
use CommerceLinkBuilder\Presentation\Rest\Routing\RouteDefinition;
use CommerceLinkBuilder\Presentation\Rest\Routing\RouteRegistrar;
use CommerceLinkBuilder\Presentation\Rest\Validation\RequestValidator;
use Closure;
use LogicException;

/**
 * Binds the three §10.1-§10.3 editor use cases plus the composing
 * {@see EditorController}, then appends three
 * {@see RouteDefinition} values to the bound {@see RouteRegistrar}
 * so the §6a `rest_api_init` driver forwards them to WordPress.
 *
 * See the file-level docblock for the bootstrap-pipeline ordering
 * requirement, the Phase-6 minimum behaviors (empty provider
 * registry for §10.3, permission-callback always-true posture,
 * synthetic `_http_status` extraction), and the resolution policy.
 */
final class EditorServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the §10.1
	 * {@see PreviewProductCardUseCase} is bound.
	 */
	public const BINDING_PREVIEW_USE_CASE = 'clb.rest.editor.preview_use_case';

	/**
	 * Registry key under which the §10.2
	 * {@see ListAvailableTemplatesUseCase} is bound.
	 */
	public const BINDING_TEMPLATES_USE_CASE = 'clb.rest.editor.templates_use_case';

	/**
	 * Registry key under which the §10.3
	 * {@see ListAvailableProvidersUseCase} is bound.
	 */
	public const BINDING_PROVIDERS_USE_CASE = 'clb.rest.editor.providers_use_case';

	/**
	 * Registry key under which the composed
	 * {@see EditorController} is bound.
	 */
	public const BINDING_CONTROLLER = 'clb.rest.editor_controller';

	/**
	 * §10.1 preview use case override.
	 */
	private ?PreviewProductCardUseCase $previewUseCaseOverride;

	/**
	 * §10.2 templates use case override.
	 */
	private ?ListAvailableTemplatesUseCase $templatesUseCaseOverride;

	/**
	 * §10.3 providers use case override.
	 */
	private ?ListAvailableProvidersUseCase $providersUseCaseOverride;

	/**
	 * Composed controller override.
	 */
	private ?EditorController $controllerOverride;

	/**
	 * Cached {@see EditorController} bound into the registry.
	 *
	 * Populated during register() so the route-callback closures
	 * can capture the same instance without re-resolving the
	 * registry binding.
	 */
	private ?EditorController $resolvedController = null;

	/**
	 * Construct an editor service provider with optional overrides
	 * for each collaborator.
	 *
	 * Empty / no-arg construction is the production default; tests
	 * pass overrides one axis at a time so the controller graph
	 * can be exercised against synthetic use cases without standing
	 * up the persistence or render stack.
	 *
	 * @param PreviewProductCardUseCase|null     $previewUseCaseOverride   §10.1 use case override.
	 * @param ListAvailableTemplatesUseCase|null $templatesUseCaseOverride §10.2 use case override.
	 * @param ListAvailableProvidersUseCase|null $providersUseCaseOverride §10.3 use case override.
	 * @param EditorController|null              $controllerOverride       Composed controller override.
	 */
	public function __construct(
		?PreviewProductCardUseCase $previewUseCaseOverride = null,
		?ListAvailableTemplatesUseCase $templatesUseCaseOverride = null,
		?ListAvailableProvidersUseCase $providersUseCaseOverride = null,
		?EditorController $controllerOverride = null
	) {
		$this->previewUseCaseOverride   = $previewUseCaseOverride;
		$this->templatesUseCaseOverride = $templatesUseCaseOverride;
		$this->providersUseCaseOverride = $providersUseCaseOverride;
		$this->controllerOverride       = $controllerOverride;
	}

	/**
	 * Bind the three §10.1-§10.3 use cases plus the composed
	 * {@see EditorController}, then append three
	 * {@see RouteDefinition} values to the bound
	 * {@see RouteRegistrar}.
	 *
	 * Resolution order:
	 *
	 *   1. Resolve {@see ProductRepository} via
	 *      {@see PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY}.
	 *      Missing binding raises {@see LogicException} so a
	 *      misordered bootstrap fails loudly here rather than at
	 *      the first REST request.
	 *
	 *   2. Resolve {@see ProductCardViewModelFactory},
	 *      {@see ProductCardRenderer}, and
	 *      {@see ViewModelArrayDumper} via the corresponding
	 *      {@see RenderServiceProvider} `BINDING_*` keys. Missing
	 *      bindings each raise {@see LogicException}.
	 *
	 *   3. Build the three use cases (using the constructor
	 *      overrides when supplied, else default-construct against
	 *      the resolved render collaborators and the empty
	 *      provider registry described on the file-level
	 *      docblock).
	 *
	 *   4. Build the {@see EditorController} (using the
	 *      constructor override when supplied, else default-
	 *      construct with fresh validator / adapter / capability
	 *      policy / exception mapper; the controller's own null
	 *      defaults supply the user-id and capability providers).
	 *
	 *   5. Bind the use cases and the controller under the
	 *      `BINDING_*` keys and cache the controller on this
	 *      provider so the route-callback closures can capture it.
	 *
	 *   6. Resolve {@see RouteRegistrar} via
	 *      {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}.
	 *      Missing binding raises {@see LogicException} for the
	 *      same reason as step 1.
	 *
	 *   7. Append three {@see RouteDefinition} values matching
	 *      `rest-api-spec.md` §10.1-§10.3 verbatim, each wrapping
	 *      the controller method in a thin closure that extracts
	 *      and strips the synthetic
	 *      {@see EditorController::META_HTTP_STATUS} key from
	 *      the returned envelope and stamps it onto a
	 *      {@see \WP_REST_Response} (or the pure-unit-mode array
	 *      shim).
	 *
	 * @param ServiceProviderRegistry $registry Registry driving
	 *                                          this bootstrap pass.
	 *
	 * @throws LogicException When the persistence, render, or REST
	 *                        provider has not registered before
	 *                        this one.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		$repository  = $this->resolveProductRepository( $registry );
		$factory     = $this->resolveViewModelFactory( $registry );
		$renderer    = $this->resolveRenderer( $registry );
		$arrayDumper = $this->resolveArrayDumper( $registry );

		$previewUseCase   = $this->previewUseCaseOverride
			?? new PreviewProductCardUseCase( $repository, $factory, $renderer, $arrayDumper );
		$templatesUseCase = $this->templatesUseCaseOverride ?? new ListAvailableTemplatesUseCase();
		$providersUseCase = $this->providersUseCaseOverride ?? new ListAvailableProvidersUseCase(
			$this->resolveProviderRegistry( $registry )->all()
		);

		$controller = $this->controllerOverride ?? new EditorController(
			$previewUseCase,
			$templatesUseCase,
			$providersUseCase,
			new ProviderExceptionMapper(),
			new CapabilityPolicy(),
			new RequestValidator(),
			new RestRequestAdapter()
		);

		$this->resolvedController = $controller;

		$registry->bind( self::BINDING_PREVIEW_USE_CASE, $previewUseCase );
		$registry->bind( self::BINDING_TEMPLATES_USE_CASE, $templatesUseCase );
		$registry->bind( self::BINDING_PROVIDERS_USE_CASE, $providersUseCase );
		$registry->bind( self::BINDING_CONTROLLER, $controller );

		$registrar = $this->resolveRouteRegistrar( $registry );
		$this->appendRoutes( $registrar );
	}

	/**
	 * No-op — routes are registered via
	 * {@see RestServiceProvider}'s `rest_api_init` driver, which
	 * forwards every accumulated {@see RouteDefinition} to
	 * WordPress at the canonical registration moment.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		unset( $hooks );
	}

	/**
	 * Resolve {@see ProductRepository} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProductRepository}.
	 */
	private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository {
		$key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'EditorServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing ProductRepository binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$repository = $registry->get( $key );
		if ( ! $repository instanceof ProductRepository ) {
			$message = sprintf(
				'EditorServiceProvider expected ProductRepository under "%s"; got %s.',
				$key,
				is_object( $repository ) ? get_class( $repository ) : gettype( $repository )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $repository;
	}

	/**
	 * Resolve the {@see ProviderRegistry} from the registry.
	 *
	 * Phase 12 slice 12c centralizes the §10.3 provider list under
	 * {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}.
	 * When the binding is missing — typically in tests that
	 * pre-seed the editor collaborators without standing up the
	 * provider service provider — this method returns a fresh
	 * empty {@see ProviderRegistry} so the §10.3 enumeration
	 * answers `items: []` rather than failing loudly. That
	 * fallback mirrors the Phase-6 minimum behavior the
	 * {@see ListAvailableProvidersUseCase} constructor already
	 * documents on the empty-registry case.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding exists but is not a
	 *                        {@see ProviderRegistry}.
	 */
	private function resolveProviderRegistry( ServiceProviderRegistry $registry ): ProviderRegistry {
		$key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY;
		if ( ! $registry->has( $key ) ) {
			return new ProviderRegistry( array() );
		}

		$value = $registry->get( $key );
		if ( ! $value instanceof ProviderRegistry ) {
			$message = sprintf(
				'EditorServiceProvider expected ProviderRegistry under "%s"; got %s.',
				$key,
				is_object( $value ) ? get_class( $value ) : gettype( $value )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $value;
	}

	/**
	 * Resolve {@see ProductCardViewModelFactory} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProductCardViewModelFactory}.
	 */
	private function resolveViewModelFactory(
		ServiceProviderRegistry $registry
	): ProductCardViewModelFactory {
		$key = RenderServiceProvider::BINDING_PRODUCT_CARD_VIEW_MODEL_FACTORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'EditorServiceProvider requires RenderServiceProvider to register before it'
				. ' (missing ProductCardViewModelFactory binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$factory = $registry->get( $key );
		if ( ! $factory instanceof ProductCardViewModelFactory ) {
			$message = sprintf(
				'EditorServiceProvider expected ProductCardViewModelFactory under "%s"; got %s.',
				$key,
				is_object( $factory ) ? get_class( $factory ) : gettype( $factory )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $factory;
	}

	/**
	 * Resolve {@see ProductCardRenderer} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProductCardRenderer}.
	 */
	private function resolveRenderer( ServiceProviderRegistry $registry ): ProductCardRenderer {
		$key = RenderServiceProvider::BINDING_PRODUCT_CARD_RENDERER;
		if ( ! $registry->has( $key ) ) {
			$message = 'EditorServiceProvider requires RenderServiceProvider to register before it'
				. ' (missing ProductCardRenderer binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$renderer = $registry->get( $key );
		if ( ! $renderer instanceof ProductCardRenderer ) {
			$message = sprintf(
				'EditorServiceProvider expected ProductCardRenderer under "%s"; got %s.',
				$key,
				is_object( $renderer ) ? get_class( $renderer ) : gettype( $renderer )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $renderer;
	}

	/**
	 * Resolve {@see ViewModelArrayDumper} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ViewModelArrayDumper}.
	 */
	private function resolveArrayDumper( ServiceProviderRegistry $registry ): ViewModelArrayDumper {
		$key = RenderServiceProvider::BINDING_VIEW_MODEL_ARRAY_DUMPER;
		if ( ! $registry->has( $key ) ) {
			$message = 'EditorServiceProvider requires RenderServiceProvider to register before it'
				. ' (missing ViewModelArrayDumper binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$dumper = $registry->get( $key );
		if ( ! $dumper instanceof ViewModelArrayDumper ) {
			$message = sprintf(
				'EditorServiceProvider expected ViewModelArrayDumper under "%s"; got %s.',
				$key,
				is_object( $dumper ) ? get_class( $dumper ) : gettype( $dumper )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $dumper;
	}

	/**
	 * Resolve {@see RouteRegistrar} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see RouteRegistrar}.
	 */
	private function resolveRouteRegistrar( ServiceProviderRegistry $registry ): RouteRegistrar {
		$key = RestServiceProvider::BINDING_ROUTE_REGISTRAR;
		if ( ! $registry->has( $key ) ) {
			$message = 'EditorServiceProvider requires RestServiceProvider to register before it'
				. ' (missing RouteRegistrar binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$registrar = $registry->get( $key );
		if ( ! $registrar instanceof RouteRegistrar ) {
			$message = sprintf(
				'EditorServiceProvider expected RouteRegistrar under "%s"; got %s.',
				$key,
				is_object( $registrar ) ? get_class( $registrar ) : gettype( $registrar )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $registrar;
	}

	/**
	 * Append the three §10.1-§10.3 {@see RouteDefinition} values
	 * to the supplied registrar.
	 *
	 * Each definition wraps the corresponding controller method in
	 * a thin {@see Closure} built by
	 * {@see self::buildRouteCallback()} that extracts and strips
	 * the synthetic {@see EditorController::META_HTTP_STATUS}
	 * key from the returned envelope.
	 *
	 * @param RouteRegistrar $registrar Bound registrar.
	 */
	private function appendRoutes( RouteRegistrar $registrar ): void {
		$permissionCallback = static function ( $request = null ): bool {
			unset( $request );
			return true;
		};

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/editor/preview',
				RouteDefinition::METHOD_POST,
				EditorController::ROUTE_TOKEN_PREVIEW,
				array(),
				$this->buildRouteCallback( 'preview' ),
				$permissionCallback
			)
		);

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/editor/templates',
				RouteDefinition::METHOD_GET,
				EditorController::ROUTE_TOKEN_TEMPLATES,
				array(),
				$this->buildRouteCallback( 'templates' ),
				$permissionCallback
			)
		);

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/editor/providers',
				RouteDefinition::METHOD_GET,
				EditorController::ROUTE_TOKEN_PROVIDERS,
				array(),
				$this->buildRouteCallback( 'providers' ),
				$permissionCallback
			)
		);
	}

	/**
	 * Build a route-callback {@see Closure} that dispatches to the
	 * named controller method and adapts the returned envelope
	 * onto a {@see \WP_REST_Response} (or the pure-unit-mode
	 * array shim).
	 *
	 * The closure captures `$this` so the cached
	 * {@see self::$resolvedController} is read at invocation time;
	 * the three method names are resolved through a `match`
	 * expression rather than dynamic-dispatch (`->{$method}()`)
	 * so PHPStan can verify each call site against the controller's
	 * static type. The match's `default` arm raises
	 * {@see LogicException} as a defensive guard against a future
	 * caller passing an unknown method name.
	 *
	 * Synthetic key extraction:
	 *
	 *   - The controller returns
	 *     `Envelope::toArray() + array(META_HTTP_STATUS => int)`.
	 *
	 *   - The closure reads the int (defaulting to 200 when
	 *     absent), strips the key from the array, and passes the
	 *     stripped envelope as the response body.
	 *
	 *   - When `WP_REST_Response` is loadable (production path),
	 *     the closure returns `new \WP_REST_Response($body, $status)`.
	 *     Otherwise (pure-unit harness), it returns the
	 *     `array('data' => $body, 'status' => $status)` shim.
	 *
	 * @param string $method One of `'preview'`, `'templates'`, `'providers'`.
	 */
	private function buildRouteCallback( string $method ): Closure {
		return function ( mixed $request ) use ( $method ): mixed {
			$controller = $this->resolvedController;
			if ( $controller === null ) {
				$message = 'EditorServiceProvider route callback invoked before register() ran.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new LogicException( $message );
			}

			$envelope = match ( $method ) {
				'preview'   => $controller->preview( $request ),
				'templates' => $controller->templates( $request ),
				'providers' => $controller->providers( $request ),
				default     => $this->raiseUnknownMethod( $method ),
			};

			$status = isset( $envelope[ EditorController::META_HTTP_STATUS ] )
				&& is_int( $envelope[ EditorController::META_HTTP_STATUS ] )
					? $envelope[ EditorController::META_HTTP_STATUS ]
					: 200;

			unset( $envelope[ EditorController::META_HTTP_STATUS ] );

			if ( class_exists( '\\WP_REST_Response' ) ) {
				return new \WP_REST_Response( $envelope, $status );
			}

			return array(
				'data'   => $envelope,
				'status' => $status,
			);
		};
	}

	/**
	 * Raise a {@see LogicException} for an unknown route-callback
	 * method name.
	 *
	 * Extracted as a helper so the {@see self::buildRouteCallback()}
	 * `match` expression's `default` arm can call it as an
	 * expression (the `match` arm grammar requires an expression,
	 * not a statement).
	 *
	 * @param string $method Offending method name.
	 *
	 * @return never
	 *
	 * @throws LogicException Always.
	 */
	private function raiseUnknownMethod( string $method ): array {
		$message = sprintf(
			'EditorServiceProvider route callback received unknown method "%s".',
			$method
		);
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw new LogicException( $message );
	}
}
