<?php
/**
 * Settings REST service provider.
 *
 * Phase-8 slice 8b Bootstrap-layer service provider that wires
 * the §13.1 `GET /clb/v1/settings/public` REST endpoint
 * documented in `specifications/rest-api-spec.md`. The endpoint
 * returns ONLY the public-safe subset of the §13 settings
 * aggregate; secret-bearing fields are filtered out by
 * {@see \CommerceLinkBuilder\Presentation\Rest\Mapper\PublicSettingsMapper}.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_SETTINGS_REPOSITORY}        — §13 repository.
 *   - {@see self::BINDING_GET_PUBLIC_SETTINGS_USE_CASE} — §13.1 use case.
 *   - {@see self::BINDING_PUBLIC_SETTINGS_MAPPER}     — §13.1 response mapper.
 *   - {@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_OPTION_STORE}).
 *
 *   2. {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider}
 *      (publishes
 *      {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider::BINDING_ROUTE_REGISTRAR}).
 *
 *   3. This provider (resolves both bindings, appends a single
 *      {@see \CommerceLinkBuilder\Presentation\Rest\Routing\RouteDefinition}
 *      to the registrar so the §6a `rest_api_init` driver
 *      forwards it to WordPress at the canonical registration
 *      moment).
 *
 * Phase-8 minimum behaviors (documented, never silently dropped):
 *
 *   - **WP REST permission_callback always returns true.** The
 *     §3.2 / §13.1 capability check runs inside
 *     {@see SettingsController::readPublic()} via the injected
 *     {@see \CommerceLinkBuilder\Presentation\Rest\Permission\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 returns the §4 envelope array PLUS
 *     {@see SettingsController::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 an `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\Settings\OptionStoreSettingsRepository;
use CommerceLinkBuilder\Application\Settings\SettingsRepository;
use CommerceLinkBuilder\Application\Settings\UseCase\GetPublicSettingsUseCase;
use CommerceLinkBuilder\Bootstrap\HookRegistrar;
use CommerceLinkBuilder\Bootstrap\ServiceProvider;
use CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry;
use CommerceLinkBuilder\Infrastructure\Persistence\OptionStore;
use CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider;
use CommerceLinkBuilder\Presentation\Rest\Controller\RestRequestAdapter;
use CommerceLinkBuilder\Presentation\Rest\Controller\SettingsController;
use CommerceLinkBuilder\Presentation\Rest\Mapper\PublicSettingsMapper;
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 §13.1 settings repository, use case, response
 * mapper, and the composing {@see SettingsController}, then
 * appends a single {@see RouteDefinition} 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-8 minimum behaviors
 * (permission-callback always-true posture, synthetic
 * `_http_status` extraction), and the resolution policy.
 */
final class SettingsServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the {@see SettingsRepository} is
	 * bound.
	 */
	public const BINDING_SETTINGS_REPOSITORY = 'clb.settings.repository';

	/**
	 * Registry key under which the §13.1
	 * {@see GetPublicSettingsUseCase} is bound.
	 */
	public const BINDING_GET_PUBLIC_SETTINGS_USE_CASE = 'clb.settings.get_public_use_case';

	/**
	 * Registry key under which the §13.1
	 * {@see PublicSettingsMapper} is bound.
	 */
	public const BINDING_PUBLIC_SETTINGS_MAPPER = 'clb.settings.public_mapper';

	/**
	 * Registry key under which the composed
	 * {@see SettingsController} is bound.
	 */
	public const BINDING_CONTROLLER = 'clb.settings.controller';

	/**
	 * §13 repository override.
	 */
	private ?SettingsRepository $repositoryOverride;

	/**
	 * §13.1 use case override.
	 */
	private ?GetPublicSettingsUseCase $useCaseOverride;

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

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

	/**
	 * Construct a settings 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 collaborators
	 * without standing up the persistence or REST stack.
	 *
	 * @param SettingsRepository|null       $repositoryOverride §13 repository override.
	 * @param GetPublicSettingsUseCase|null $useCaseOverride    §13.1 use case override.
	 * @param SettingsController|null       $controllerOverride Composed controller override.
	 */
	public function __construct(
		?SettingsRepository $repositoryOverride = null,
		?GetPublicSettingsUseCase $useCaseOverride = null,
		?SettingsController $controllerOverride = null
	) {
		$this->repositoryOverride = $repositoryOverride;
		$this->useCaseOverride    = $useCaseOverride;
		$this->controllerOverride = $controllerOverride;
	}

	/**
	 * Bind the §13 repository, §13.1 use case, response mapper,
	 * and composed {@see SettingsController}, then append the
	 * §13.1 {@see RouteDefinition} to the bound
	 * {@see RouteRegistrar}.
	 *
	 * Resolution order:
	 *
	 *   1. Resolve {@see OptionStore} via
	 *      {@see PersistenceServiceProvider::BINDING_OPTION_STORE}.
	 *      Missing binding raises {@see LogicException} so a
	 *      misordered bootstrap fails loudly here rather than at
	 *      the first REST request.
	 *
	 *   2. Build the {@see SettingsRepository} (using the
	 *      constructor override when supplied, else default-
	 *      construct an {@see OptionStoreSettingsRepository}
	 *      against the resolved {@see OptionStore}).
	 *
	 *   3. Build the {@see GetPublicSettingsUseCase} (using the
	 *      constructor override when supplied, else default-
	 *      construct against the resolved repository).
	 *
	 *   4. Build a fresh {@see PublicSettingsMapper}.
	 *
	 *   5. Build the {@see SettingsController} (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).
	 *
	 *   6. Bind all four under the `BINDING_*` keys and cache
	 *      the controller on this provider so the route-callback
	 *      closure can capture it.
	 *
	 *   7. Resolve {@see RouteRegistrar} via
	 *      {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}.
	 *      Missing binding raises {@see LogicException}.
	 *
	 *   8. Append the §13.1 {@see RouteDefinition} to the
	 *      registrar, wrapping
	 *      {@see SettingsController::readPublic()} in a thin
	 *      closure that extracts and strips the synthetic
	 *      {@see SettingsController::META_HTTP_STATUS} key.
	 *
	 * @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 {
		$optionStore = $this->resolveOptionStore( $registry );

		$repository = $this->repositoryOverride ?? new OptionStoreSettingsRepository( $optionStore );
		$useCase    = $this->useCaseOverride ?? new GetPublicSettingsUseCase( $repository );
		$mapper     = new PublicSettingsMapper();

		$controller = $this->controllerOverride ?? new SettingsController(
			$useCase,
			$mapper,
			new ProviderExceptionMapper(),
			new CapabilityPolicy(),
			new RequestValidator(),
			new RestRequestAdapter()
		);

		$this->resolvedController = $controller;

		$registry->bind( self::BINDING_SETTINGS_REPOSITORY, $repository );
		$registry->bind( self::BINDING_GET_PUBLIC_SETTINGS_USE_CASE, $useCase );
		$registry->bind( self::BINDING_PUBLIC_SETTINGS_MAPPER, $mapper );
		$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 OptionStore} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not an
	 *                        {@see OptionStore}.
	 */
	private function resolveOptionStore( ServiceProviderRegistry $registry ): OptionStore {
		$key = PersistenceServiceProvider::BINDING_OPTION_STORE;
		if ( ! $registry->has( $key ) ) {
			$message = 'SettingsServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing OptionStore binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

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

		return $optionStore;
	}

	/**
	 * 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 = 'SettingsServiceProvider 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(
				'SettingsServiceProvider 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 §13.1 {@see RouteDefinition} to the supplied
	 * registrar.
	 *
	 * The definition wraps {@see SettingsController::readPublic()}
	 * in a thin {@see Closure} built by
	 * {@see self::buildRouteCallback()} that extracts and strips
	 * the synthetic {@see SettingsController::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,
				'/settings/public',
				RouteDefinition::METHOD_GET,
				SettingsController::ROUTE_TOKEN_PUBLIC_GET,
				array(),
				$this->buildRouteCallback(),
				$permissionCallback
			)
		);
	}

	/**
	 * Build a route-callback {@see Closure} that dispatches to
	 * {@see SettingsController::readPublic()} 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 = 'SettingsServiceProvider 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->readPublic( $request );

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

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

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

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