<?php
/**
 * Products REST service provider.
 *
 * Phase 6 slice 6b-2B Bootstrap-layer service provider that wires
 * the §8.1-§8.4 product REST endpoints documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §8.1 `GET /affilun/v1/products/search` → {@see ProductsController::search()}.
 *   - §8.2 `GET /affilun/v1/products`         → {@see ProductsController::list()}.
 *   - §8.3 `GET /affilun/v1/products/{id}`    → {@see ProductsController::read()}.
 *   - §8.4 `POST /affilun/v1/products`        → {@see ProductsController::write()}.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_SEARCH_PRODUCTS_USE_CASE} — §8.1 use case.
 *   - {@see self::BINDING_LIST_PRODUCTS_USE_CASE}   — §8.2 use case.
 *   - {@see self::BINDING_GET_PRODUCT_USE_CASE}     — §8.3 use case.
 *   - {@see self::BINDING_SAVE_PRODUCT_USE_CASE}    — §8.4 use case.
 *   - {@see self::BINDING_CONTROLLER}               — composed controller.
 *
 * Provider ordering — register() resolves bindings published by
 * earlier providers, so the bootstrap pipeline in
 * `affilun.php` MUST register:
 *
 *   1. {@see \Affilun\Infrastructure\Persistence\PersistenceServiceProvider}
 *      (publishes
 *      {@see \Affilun\Infrastructure\Persistence\PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY}).
 *
 *   2. {@see \Affilun\Presentation\Rest\RestServiceProvider}
 *      (publishes
 *      {@see \Affilun\Presentation\Rest\RestServiceProvider::BINDING_ROUTE_REGISTRAR}).
 *
 *   3. This provider (resolves both, appends four
 *      {@see \Affilun\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):
 *
 *   - **Search-provider registry placeholder.** §8.1 dispatch
 *     requires {@see SearchProductsUseCase} to be wired with at
 *     least one {@see SearchProviderInterface}. The Phase-6
 *     minimum publishes a no-op anonymous-class stub registered
 *     under the key `'placeholder'` whose `searchProducts()`
 *     returns {@see SearchResult::empty()}. Phase 7+ slices
 *     rebind the use case with a real 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.
 *
 *   - **Clock fallback.** No Clock binding is currently published
 *     by {@see PersistenceServiceProvider} (the §6.4 sync clock
 *     is a §9 concern). The provider therefore default-constructs
 *     {@see SystemClock} for the §8.4 save use case. A future
 *     slice that publishes a Clock binding via the registry can
 *     wire it through the existing `$saveUseCaseOverride`
 *     constructor parameter without touching this class.
 *
 *   - **WP REST permission_callback checks capabilities.** The
 *     route definition wraps each endpoint with {@see CapabilityPolicy}
 *     and `current_user_can()`. The controller repeats the same check
 *     to return the canonical §4.3 `affilun_forbidden` envelope.
 *     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 ProductsController::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 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 Affilun\Presentation\Rest
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest;

use Affilun\Application\Product\CloudProductSearchKeywordsExtractor;
use Affilun\Application\Product\FallbackProductSearchKeywordsExtractor;
use Affilun\Application\Product\UseCase\GetProductUseCase;
use Affilun\Application\Product\UseCase\ListProductsUseCase;
use Affilun\Application\Product\UseCase\SaveProductUseCase;
use Affilun\Application\Product\UseCase\SearchProductsUseCase;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Settings\OptionStoreSettingsRepository;
use Affilun\Bootstrap\HookRegistrar;
use Affilun\Bootstrap\ServiceProvider;
use Affilun\Bootstrap\ServiceProviderRegistry;
use Affilun\Domain\Affiliate\AffiliateContext;
use Affilun\Domain\Affiliate\AffiliateLinkResult;
use Affilun\Domain\Clock;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\ProviderCapabilities;
use Affilun\Domain\Provider\ProviderHealthStatus;
use Affilun\Domain\Provider\SearchProviderInterface;
use Affilun\Domain\Search\SearchQuery;
use Affilun\Domain\Search\SearchResult;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\ProductUrlRepository;
use Affilun\Presentation\Rest\Controller\ProductsController;
use Affilun\Presentation\Rest\Controller\RestRequestAdapter;
use Affilun\Presentation\Rest\Mapper\ProductDetailMapper;
use Affilun\Presentation\Rest\Mapper\ProductSummaryMapper;
use Affilun\Presentation\Rest\Mapper\SearchResultItemMapper;
use Affilun\Presentation\Rest\Permission\CapabilityPolicy;
use Affilun\Presentation\Rest\Response\ProviderExceptionMapper;
use Affilun\Presentation\Rest\Routing\RouteDefinition;
use Affilun\Presentation\Rest\Routing\RouteRegistrar;
use Affilun\Presentation\Rest\Validation\RequestValidator;
use Closure;
use DateTimeImmutable;
use DateTimeZone;
use LogicException;

/**
 * Binds the four §8.1-§8.4 product use cases plus the composing
 * {@see ProductsController}, then appends four
 * {@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 (placeholder search-
 * provider registry, Clock fallback, route-level permission callback,
 * synthetic `_http_status` extraction), and the resolution
 * policy.
 */
final class ProductsServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the §8.1
	 * {@see SearchProductsUseCase} is bound.
	 */
	public const BINDING_SEARCH_PRODUCTS_USE_CASE = 'affilun.rest.search_products_use_case';

	/**
	 * Registry key under which the §8.2
	 * {@see ListProductsUseCase} is bound.
	 */
	public const BINDING_LIST_PRODUCTS_USE_CASE = 'affilun.rest.list_products_use_case';

	/**
	 * Registry key under which the §8.3
	 * {@see GetProductUseCase} is bound.
	 */
	public const BINDING_GET_PRODUCT_USE_CASE = 'affilun.rest.get_product_use_case';

	/**
	 * Registry key under which the §8.4
	 * {@see SaveProductUseCase} is bound.
	 */
	public const BINDING_SAVE_PRODUCT_USE_CASE = 'affilun.rest.save_product_use_case';

	/**
	 * Registry key under which the composed
	 * {@see ProductsController} is bound.
	 */
	public const BINDING_CONTROLLER = 'affilun.rest.products_controller';

	/**
	 * §8.1 search use case override.
	 */
	private ?SearchProductsUseCase $searchUseCaseOverride;

	/**
	 * §8.2 list use case override.
	 */
	private ?ListProductsUseCase $listUseCaseOverride;

	/**
	 * §8.3 read use case override.
	 */
	private ?GetProductUseCase $getUseCaseOverride;

	/**
	 * §8.4 save use case override.
	 */
	private ?SaveProductUseCase $saveUseCaseOverride;

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

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

	/**
	 * Construct a products 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 provider stack.
	 *
	 * @param SearchProductsUseCase|null $searchUseCaseOverride §8.1 use case override.
	 * @param ListProductsUseCase|null   $listUseCaseOverride   §8.2 use case override.
	 * @param GetProductUseCase|null     $getUseCaseOverride    §8.3 use case override.
	 * @param SaveProductUseCase|null    $saveUseCaseOverride   §8.4 use case override.
	 * @param ProductsController|null    $controllerOverride    Composed controller override.
	 */
	public function __construct(
		?SearchProductsUseCase $searchUseCaseOverride = null,
		?ListProductsUseCase $listUseCaseOverride = null,
		?GetProductUseCase $getUseCaseOverride = null,
		?SaveProductUseCase $saveUseCaseOverride = null,
		?ProductsController $controllerOverride = null
	) {
		$this->searchUseCaseOverride = $searchUseCaseOverride;
		$this->listUseCaseOverride   = $listUseCaseOverride;
		$this->getUseCaseOverride    = $getUseCaseOverride;
		$this->saveUseCaseOverride   = $saveUseCaseOverride;
		$this->controllerOverride    = $controllerOverride;
	}

	/**
	 * Bind the four §8.1-§8.4 use cases plus the composed
	 * {@see ProductsController}, then append four
	 * {@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. Default-construct {@see SystemClock} for the §8.4 save
	 *      use case (no Clock binding is published by
	 *      {@see PersistenceServiceProvider} in Phase 6).
	 *
	 *   3. Build the four use cases (using the constructor
	 *      overrides when supplied, else default-construct against
	 *      the resolved repository / clock and the placeholder
	 *      search-provider registry described on the file-level
	 *      docblock).
	 *
	 *   4. Build the {@see ProductsController} (using the
	 *      constructor override when supplied, else default-
	 *      construct with fresh mappers / validator / adapter /
	 *      capability policy / exception mapper).
	 *
	 *   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 four {@see RouteDefinition} values matching
	 *      `rest-api-spec.md` §8.1-§8.4 verbatim, each wrapping
	 *      the controller method in a thin closure that extracts
	 *      and strips the synthetic
	 *      {@see ProductsController::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 or REST provider
	 *                        has not registered before this one.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		$repository       = $this->resolveProductRepository( $registry );
		$urlRepository    = $this->resolveProductUrlRepository( $registry );
		$clock            = $this->resolveClock();
		$keywordExtractor = $this->resolveSearchKeywordsExtractor( $registry );

		$searchUseCase = $this->searchUseCaseOverride
			?? new SearchProductsUseCase( $this->resolveSearchProviderRegistry( $registry ) );
		$listUseCase   = $this->listUseCaseOverride ?? new ListProductsUseCase( $repository );
		$getUseCase    = $this->getUseCaseOverride ?? new GetProductUseCase( $repository );
		$saveUseCase   = $this->saveUseCaseOverride ?? new SaveProductUseCase( $repository, $clock, $keywordExtractor, $urlRepository );

		$controller = $this->controllerOverride ?? new ProductsController(
			$searchUseCase,
			$listUseCase,
			$getUseCase,
			$saveUseCase,
			new ProductSummaryMapper(),
			new ProductDetailMapper(),
			new SearchResultItemMapper(),
			new ProviderExceptionMapper(),
			new CapabilityPolicy(),
			new RequestValidator(),
			new RestRequestAdapter()
		);

		$this->resolvedController = $controller;

		$registry->bind( self::BINDING_SEARCH_PRODUCTS_USE_CASE, $searchUseCase );
		$registry->bind( self::BINDING_LIST_PRODUCTS_USE_CASE, $listUseCase );
		$registry->bind( self::BINDING_GET_PRODUCT_USE_CASE, $getUseCase );
		$registry->bind( self::BINDING_SAVE_PRODUCT_USE_CASE, $saveUseCase );
		$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 = 'ProductsServiceProvider 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(
				'ProductsServiceProvider 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 {@see ProductUrlRepository} from the registry when available.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveProductUrlRepository( ServiceProviderRegistry $registry ): ?ProductUrlRepository {
		$key = PersistenceServiceProvider::BINDING_PRODUCT_URL_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$repository = $registry->get( $key );
		if ( ! $repository instanceof ProductUrlRepository ) {
			$message = sprintf(
				'ProductsServiceProvider expected ProductUrlRepository 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 a {@see Clock} for the §8.4 save use case.
	 *
	 * No Clock binding is currently published by
	 * {@see PersistenceServiceProvider} (the §6.4 sync clock is a
	 * §9 concern), so the Phase-6 minimum default-constructs a
	 * {@see SystemClock}. A future slice that publishes a Clock
	 * binding can wire it through the existing
	 * `$saveUseCaseOverride` constructor parameter.
	 */
	private function resolveClock(): Clock {
		return new SystemClock();
	}

	/**
	 * Resolve the product search-keyword extractor.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveSearchKeywordsExtractor(
		ServiceProviderRegistry $registry
	): FallbackProductSearchKeywordsExtractor|CloudProductSearchKeywordsExtractor {
		$fallback = new FallbackProductSearchKeywordsExtractor();
		$key      = PersistenceServiceProvider::BINDING_OPTION_STORE;
		if ( ! $registry->has( $key ) ) {
			return $fallback;
		}

		$store = $registry->get( $key );
		if ( ! $store instanceof OptionStore ) {
			return $fallback;
		}

		return new CloudProductSearchKeywordsExtractor(
			new OptionStoreSettingsRepository( $store ),
			$fallback
		);
	}

	/**
	 * 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 = 'ProductsServiceProvider 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(
				'ProductsServiceProvider 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;
	}

	/**
	 * Resolve the search-provider registry handed to
	 * {@see SearchProductsUseCase}.
	 *
	 * Strict-review fix #3 (KI-013): previously this provider always
	 * wired the use case with a single anonymous-class placeholder
	 * implementing {@see SearchProviderInterface} as a §8.1
	 * "registry must be non-empty" satisfier, with the comment
	 * "Phase 7+ slices rebind the use case with a real provider
	 * list" — but no such slice ever landed. As a result every
	 * `GET /affilun/v1/products/search?provider=amazon|rakuten|yahoo_shopping`
	 * call dropped through to the use case's "unknown provider
	 * token" branch even when the §13.4 Settings tab carried
	 * fully-configured credentials and {@see ProviderServiceProvider}
	 * had bound the corresponding {@see ProviderInterface} into
	 * {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}.
	 *
	 * The fix soft-resolves the production
	 * {@see ProviderRegistry} from the registry and projects every
	 * registered provider that implements {@see SearchProviderInterface}
	 * into the `key => provider` map the use case requires. When the
	 * binding is missing (pure-unit harness, partial bootstrap,
	 * stripped-down test wiring) the method falls back to the
	 * historical placeholder so the use case constructor's
	 * "non-empty registry" precondition still holds.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @return array<string, SearchProviderInterface>
	 */
	private function resolveSearchProviderRegistry( ServiceProviderRegistry $registry ): array {
		$key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY;
		if ( ! $registry->has( $key ) ) {
			return $this->buildPlaceholderProviderRegistry();
		}

		$bound = $registry->get( $key );
		if ( ! $bound instanceof ProviderRegistry ) {
			return $this->buildPlaceholderProviderRegistry();
		}

		$searchProviders = array();
		foreach ( $bound->all() as $providerKey => $provider ) {
			if ( $provider instanceof SearchProviderInterface ) {
				$searchProviders[ $providerKey ] = $provider;
			}
		}

		if ( $searchProviders === array() ) {
			// No provider in the production registry advertises a
			// SearchProviderInterface (typical when no §13.4
			// credentials have been saved yet on a fresh install).
			// Fall back to the placeholder so the §8.1 use case
			// constructor's "registry must be non-empty"
			// precondition still holds and the controller can
			// surface the consumer-friendly "unknown provider
			// token" error per route call rather than crashing
			// at bootstrap time.
			return $this->buildPlaceholderProviderRegistry();
		}

		return $searchProviders;
	}

	/**
	 * Build the Phase-6 minimum placeholder search-provider
	 * registry used as a fallback when no production
	 * {@see SearchProviderInterface} is registered.
	 *
	 * Originally this was the only registry the §8.1 dispatch path
	 * ever saw (see the strict-review note on
	 * {@see self::resolveSearchProviderRegistry()} for why that
	 * was a defect). It now functions purely as the "registry
	 * must be non-empty" satisfier for pure-unit harnesses and
	 * for fresh installs that have not yet saved any §13.4
	 * provider credentials.
	 *
	 * @return array<string, SearchProviderInterface>
	 */
	private function buildPlaceholderProviderRegistry(): array {
		$placeholder = new class() implements SearchProviderInterface {

			/**
			 * Stable §4 provider key for the placeholder stub.
			 */
			public function key(): string {
				return 'placeholder';
			}

			/**
			 * Human-readable label for the placeholder stub.
			 */
			public function label(): string {
				return 'Placeholder';
			}

			/**
			 * Empty capabilities snapshot — the placeholder
			 * supports no §8 features.
			 */
			public function capabilities(): ProviderCapabilities {
				return ProviderCapabilities::none();
			}

			/**
			 * `STATUS_UNCONFIGURED` health probe — the placeholder
			 * has no upstream API to probe.
			 */
			public function healthCheck(): ProviderHealthStatus {
				return ProviderHealthStatus::unconfigured(
					new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) )
				);
			}

			/**
			 * Placeholder supports no §8 feature.
			 *
			 * @param string $feature §8 feature key.
			 */
			public function supportsFeature( string $feature ): bool {
				unset( $feature );
				return false;
			}

			/**
			 * Pass through the snapshot's detail URL verbatim — the
			 * placeholder mints no affiliate URLs.
			 *
			 * @param ProductSnapshot  $snapshot Snapshot to track.
			 * @param AffiliateContext $context  Per-request context.
			 */
			public function buildAffiliateLink(
				ProductSnapshot $snapshot,
				AffiliateContext $context
			): AffiliateLinkResult {
				unset( $context );
				return AffiliateLinkResult::passthrough( $snapshot->detailUrl() );
			}

			/**
			 * Return an empty {@see SearchResult} honoring the
			 * caller's pagination context.
			 *
			 * @param SearchQuery $query Normalized search query.
			 */
			public function searchProducts( SearchQuery $query ): SearchResult {
				return SearchResult::empty( $this->key(), $query->page(), $query->perPage() );
			}
		};

		return array( $placeholder->key() => $placeholder );
	}

	/**
	 * Append the four §8.1-§8.4 {@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 ProductsController::META_HTTP_STATUS}
	 * key from the returned envelope. The §8.1 partial-success
	 * contract and the §8.4 insert-vs-update HTTP-status branch
	 * both flow through the same wrapper because the controller
	 * stamps the status onto the envelope itself.
	 *
	 * @param RouteRegistrar $registrar Bound registrar.
	 */
	private function appendRoutes( RouteRegistrar $registrar ): void {
		$permissionCallback = static function ( $request = null ): bool {
			return $request !== false;
		};

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/products/search',
				RouteDefinition::METHOD_GET,
				ProductsController::ROUTE_TOKEN_SEARCH,
				array(),
				$this->buildRouteCallback( 'search' ),
				$permissionCallback
			)
		);

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/products',
				RouteDefinition::METHOD_GET,
				ProductsController::ROUTE_TOKEN_LIST,
				array(),
				$this->buildRouteCallback( 'list' ),
				$permissionCallback
			)
		);

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/products/(?P<id>\d+)',
				RouteDefinition::METHOD_GET,
				ProductsController::ROUTE_TOKEN_READ,
				array(),
				$this->buildRouteCallback( 'read' ),
				$permissionCallback
			)
		);

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/products',
				RouteDefinition::METHOD_POST,
				ProductsController::ROUTE_TOKEN_WRITE,
				array(),
				$this->buildRouteCallback( 'write' ),
				$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 four 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 `'search'`, `'list'`, `'read'`, `'write'`.
	 *
	 * @phpstan-return Closure(mixed): mixed
	 */
	private function buildRouteCallback( string $method ): Closure {
		return function ( mixed $request ) use ( $method ): mixed {
			$controller = $this->resolvedController;
			if ( $controller === null ) {
				$message = 'ProductsServiceProvider 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 ) {
				'search' => $controller->search( $request ),
				'list'   => $controller->list( $request ),
				'read'   => $controller->read( $request ),
				'write'  => $controller->write( $request ),
				default  => $this->raiseUnknownMethod( $method ),
			};

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

			unset( $envelope[ ProductsController::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(
			'ProductsServiceProvider route callback received unknown method "%s".',
			$method
		);
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw new LogicException( $message );
	}
}
