<?php
/**
 * Sync REST service provider.
 *
 * Phase 9 slice 9g Bootstrap-layer service provider that wires
 * the §11.2 sync-enqueue REST endpoint documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §11.2 `POST /clb/v1/sync/products/{id}` →
 *     {@see SyncController::enqueue()}.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_ENQUEUE_USE_CASE} — §11.2 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}
 *      and
 *      {@see \CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY}).
 *
 *   2. {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider}
 *      (publishes
 *      {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider::BINDING_ROUTE_REGISTRAR}).
 *
 *   3. This provider (resolves the persistence + REST bindings,
 *      builds the §10.2 {@see SyncDispatcher} with the §12.4
 *      `sync_product` retry policy, builds the §11.2 use case +
 *      controller, and appends one
 *      {@see RouteDefinition} to the registrar so the §6a
 *      `rest_api_init` driver forwards it to WordPress at the
 *      canonical registration moment).
 *
 * Phase-9 minimum behaviors (documented, never silently dropped):
 *
 *   - **Clock binding not yet published.** No
 *     {@see \CommerceLinkBuilder\Domain\Clock} binding is currently
 *     exposed by {@see PersistenceServiceProvider}, so the provider
 *     default-constructs {@see SystemClock} for the dispatcher's
 *     §10.2 `enqueued_at` stamping. A future slice that publishes
 *     a Clock binding can rebind through the existing
 *     `$enqueueUseCaseOverride` constructor parameter.
 *
 *   - **WP REST permission_callback always returns true.** The
 *     §3.2 / §3.3 capability check runs inside
 *     {@see SyncController::enqueue()} 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.
 *
 *   - **Synthetic `_http_status` envelope key extraction.** The
 *     controller method returns the §4 envelope array PLUS
 *     {@see SyncController::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).
 *
 * 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 closure 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\Sync\SyncDispatcher;
use CommerceLinkBuilder\Application\Sync\UseCase\EnqueueProductSyncUseCase;
use CommerceLinkBuilder\Bootstrap\HookRegistrar;
use CommerceLinkBuilder\Bootstrap\ServiceProvider;
use CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry;
use CommerceLinkBuilder\Domain\Clock;
use CommerceLinkBuilder\Domain\Sync\RetryPolicy;
use CommerceLinkBuilder\Infrastructure\Clock\SystemClock;
use CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\SyncJobRepository;
use CommerceLinkBuilder\Presentation\Rest\Controller\RestRequestAdapter;
use CommerceLinkBuilder\Presentation\Rest\Controller\SyncController;
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 §11.2 use case plus the composing
 * {@see SyncController}, then appends one
 * {@see RouteDefinition} value to the bound {@see RouteRegistrar}
 * so the §6a `rest_api_init` driver forwards it to WordPress.
 *
 * See the file-level docblock for the bootstrap-pipeline ordering
 * requirement, the Phase-9 minimum behaviors (default-constructed
 * Clock, permission-callback always-true posture, synthetic
 * `_http_status` extraction), and the resolution policy.
 */
final class SyncServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the §11.2
	 * {@see EnqueueProductSyncUseCase} is bound.
	 */
	public const BINDING_ENQUEUE_USE_CASE = 'clb.rest.sync.enqueue_use_case';

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

	/**
	 * §11.2 use case override.
	 */
	private ?EnqueueProductSyncUseCase $enqueueUseCaseOverride;

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

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

	/**
	 * Construct a sync 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 stack.
	 *
	 * @param EnqueueProductSyncUseCase|null $enqueueUseCaseOverride §11.2 use case override.
	 * @param SyncController|null            $controllerOverride     Composed controller override.
	 */
	public function __construct(
		?EnqueueProductSyncUseCase $enqueueUseCaseOverride = null,
		?SyncController $controllerOverride = null
	) {
		$this->enqueueUseCaseOverride = $enqueueUseCaseOverride;
		$this->controllerOverride     = $controllerOverride;
	}

	/**
	 * Bind the §11.2 use case plus the composed
	 * {@see SyncController}, then append one
	 * {@see RouteDefinition} to the bound {@see RouteRegistrar}.
	 *
	 * Resolution order:
	 *
	 *   1. Resolve {@see ProductRepository} via
	 *      {@see PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY}
	 *      and {@see SyncJobRepository} via
	 *      {@see PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY}.
	 *      Either missing binding raises {@see LogicException} so
	 *      a misordered bootstrap fails loudly here rather than at
	 *      the first REST request.
	 *
	 *   2. Default-construct {@see SystemClock} (no Clock binding
	 *      is currently published; see file-level docblock).
	 *
	 *   3. Build the §12.4 default
	 *      {@see RetryPolicy::forJobType('sync_product')} policy.
	 *
	 *   4. Build the {@see SyncDispatcher} from those collaborators.
	 *
	 *   5. Build the §11.2 {@see EnqueueProductSyncUseCase} (using
	 *      the constructor override when supplied, else default-
	 *      construct against the dispatcher + product repository).
	 *
	 *   6. Build the {@see SyncController} (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).
	 *
	 *   7. Bind the use case and the controller under the
	 *      `BINDING_*` keys and cache the controller on this
	 *      provider so the route-callback closure can capture it.
	 *
	 *   8. Resolve {@see RouteRegistrar} via
	 *      {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}.
	 *      Missing binding raises {@see LogicException} for the
	 *      same reason as step 1.
	 *
	 *   9. Append one {@see RouteDefinition} matching
	 *      `rest-api-spec.md` §11.2 verbatim, wrapping the
	 *      controller method in a thin closure that extracts and
	 *      strips the synthetic
	 *      {@see SyncController::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 {
		$products = $this->resolveProductRepository( $registry );
		$jobs     = $this->resolveSyncJobRepository( $registry );
		$clock    = $this->resolveClock();

		$retryPolicy = RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT );
		$dispatcher  = new SyncDispatcher( $jobs, $clock, $retryPolicy );

		$enqueueUseCase = $this->enqueueUseCaseOverride
			?? new EnqueueProductSyncUseCase( $dispatcher, $products );

		$controller = $this->controllerOverride ?? new SyncController(
			$enqueueUseCase,
			new ProviderExceptionMapper(),
			new CapabilityPolicy(),
			new RequestValidator(),
			new RestRequestAdapter()
		);

		$this->resolvedController = $controller;

		$registry->bind( self::BINDING_ENQUEUE_USE_CASE, $enqueueUseCase );
		$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 = 'SyncServiceProvider 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(
				'SyncServiceProvider 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 SyncJobRepository} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see SyncJobRepository}.
	 */
	private function resolveSyncJobRepository( ServiceProviderRegistry $registry ): SyncJobRepository {
		$key = PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'SyncServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing SyncJobRepository binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

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

		return $jobs;
	}

	/**
	 * Resolve a {@see Clock} for the §10.2 dispatcher.
	 *
	 * No Clock binding is currently published by
	 * {@see PersistenceServiceProvider}, so the Phase-9 minimum
	 * default-constructs a {@see SystemClock}. A future slice that
	 * publishes a Clock binding can wire it through the existing
	 * `$enqueueUseCaseOverride` constructor parameter.
	 */
	private function resolveClock(): Clock {
		return new SystemClock();
	}

	/**
	 * 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 = 'SyncServiceProvider 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(
				'SyncServiceProvider 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 §11.2 {@see RouteDefinition} to the supplied
	 * registrar.
	 *
	 * The definition wraps the controller method in a thin
	 * {@see Closure} built by {@see self::buildRouteCallback()}
	 * that extracts and strips the synthetic
	 * {@see SyncController::META_HTTP_STATUS} key from the
	 * returned envelope.
	 *
	 * The `id` route token is constrained to a positive decimal
	 * integer via the WordPress route regex `(?P<id>\d+)`, mirroring
	 * the §8.3 `GET /clb/v1/products/{id}` registration pattern.
	 *
	 * @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,
				'/sync/products/(?P<id>\d+)',
				RouteDefinition::METHOD_POST,
				SyncController::ROUTE_TOKEN_PRODUCT_SYNC,
				array(),
				$this->buildRouteCallback(),
				$permissionCallback
			)
		);
	}

	/**
	 * Build a route-callback {@see Closure} that dispatches to
	 * {@see SyncController::enqueue()} 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.
	 *
	 * 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.
	 */
	private function buildRouteCallback(): Closure {
		return function ( mixed $request ): mixed {
			$controller = $this->resolvedController;
			if ( $controller === null ) {
				$message = 'SyncServiceProvider route callback invoked before register() ran.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new LogicException( $message );
			}

			$envelope = $controller->enqueue( $request );

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

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

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

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