<?php
/**
 * Diagnostics REST service provider.
 *
 * Phase 11 slice 11e-B Bootstrap-layer service provider that wires
 * the §12.1 `GET /affilun/v1/diagnostics` REST endpoint documented in
 * `specifications/rest-api-spec.md` to {@see DiagnosticsController::read()}.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_USE_CASE}   — §12.1 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 the
 * {@see PersistenceServiceProvider} (publishes
 * {@see PersistenceServiceProvider::BINDING_SCHEMA_VERSION_REPOSITORY}
 * and {@see PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY})
 * and the {@see RestServiceProvider} (publishes
 * {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}) before this
 * one.
 *
 * Phase-11 minimum behaviors (documented, never silently dropped):
 *
 *   - **Empty providers map.** No centralized provider registry
 *     binding is published in Phase 11; the diagnostics use case
 *     therefore receives an empty `array()` for the providers
 *     parameter and renders an empty `providers` branch on the §12.1
 *     envelope. Phase 12 will populate this from the provider
 *     registry once the seam is published.
 *
 *   - **Default-constructed Clock.** No
 *     {@see \Affilun\Domain\Clock} binding is currently
 *     exposed by {@see PersistenceServiceProvider}, so the provider
 *     default-constructs {@see SystemClock} (mirroring the
 *     {@see SyncServiceProvider} pattern). A future slice that
 *     publishes a Clock binding can rebind through the existing
 *     `$useCaseOverride` constructor parameter.
 *
 *   - **Default env provider.** The use case's null env-provider
 *     argument falls back to its own runtime probe; this provider
 *     does not customize that seam.
 *
 * @package Affilun\Presentation\Rest
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest;

use Affilun\Application\Diagnostics\UseCase\GetDiagnosticsUseCase;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Bootstrap\HookRegistrar;
use Affilun\Bootstrap\ServiceProvider;
use Affilun\Bootstrap\ServiceProviderRegistry;
use Affilun\Domain\Clock;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Infrastructure\Persistence\SchemaVersionRepository;
use Affilun\Infrastructure\Persistence\SyncJobRepository;
use Affilun\Presentation\Rest\Controller\DiagnosticsController;
use Affilun\Presentation\Rest\Controller\RestRequestAdapter;
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 Closure;
use LogicException;

/**
 * Binds the §12.1 use case plus the composing
 * {@see DiagnosticsController}, 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 empty providers map / default Clock posture, and
 * the two registry keys this provider exposes.
 */
final class DiagnosticsServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the §12.1
	 * {@see GetDiagnosticsUseCase} is bound.
	 */
	public const BINDING_USE_CASE = 'affilun.rest.diagnostics.use_case';

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

	/**
	 * §12.1 use case override.
	 */
	private ?GetDiagnosticsUseCase $useCaseOverride;

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

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

	/**
	 * Construct a diagnostics 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 GetDiagnosticsUseCase|null $useCaseOverride    §12.1 use case override.
	 * @param DiagnosticsController|null $controllerOverride Composed controller override.
	 */
	public function __construct(
		?GetDiagnosticsUseCase $useCaseOverride = null,
		?DiagnosticsController $controllerOverride = null
	) {
		$this->useCaseOverride    = $useCaseOverride;
		$this->controllerOverride = $controllerOverride;
	}

	/**
	 * Bind the §12.1 use case plus the composed
	 * {@see DiagnosticsController}, then append one
	 * {@see RouteDefinition} to the bound {@see RouteRegistrar}.
	 *
	 * @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 {
		$schemaVersions = $this->resolveSchemaVersionRepository( $registry );
		$jobs           = $this->resolveSyncJobRepository( $registry );
		$clock          = $this->resolveClock();

		// Phase 12 slice 12c: the providers map is resolved from the
		// centralized ProviderRegistry binding published by
		// ProviderServiceProvider. When the binding is missing —
		// typically in tests that do not pre-seed it — the resolver
		// falls back to an empty registry so the §12.1 envelope
		// renders an empty `providers` branch rather than failing.
		$useCase = $this->useCaseOverride ?? new GetDiagnosticsUseCase(
			$schemaVersions,
			$jobs,
			$this->resolveProviderRegistry( $registry )->all(),
			null,
			$clock
		);

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

		$this->resolvedController = $controller;

		$registry->bind( self::BINDING_USE_CASE, $useCase );
		$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.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		unset( $hooks );
	}

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

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

		return $repo;
	}

	/**
	 * 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 = 'DiagnosticsServiceProvider 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(
				'DiagnosticsServiceProvider 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 the {@see ProviderRegistry} from the registry.
	 *
	 * Phase 12 slice 12c centralizes the §12.1 providers map under
	 * {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}.
	 * When the binding is missing — typically in tests that
	 * pre-seed the diagnostics collaborators without standing up
	 * the provider service provider — this method returns a fresh
	 * empty {@see ProviderRegistry} so the §12.1 enumeration
	 * renders an empty `providers` branch rather than failing
	 * loudly. The fallback mirrors the Phase-11 minimum the
	 * {@see GetDiagnosticsUseCase} 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(
				'DiagnosticsServiceProvider 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 a {@see Clock} for the §12.1 use case.
	 *
	 * Mirrors {@see SyncServiceProvider::resolveClock()}: no Clock
	 * binding is currently published, so the Phase-11 minimum
	 * default-constructs a {@see SystemClock}.
	 */
	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 = 'DiagnosticsServiceProvider 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(
				'DiagnosticsServiceProvider 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 §12.1 {@see RouteDefinition} to the supplied
	 * registrar.
	 *
	 * @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,
				'/diagnostics',
				RouteDefinition::METHOD_GET,
				DiagnosticsController::ROUTE_TOKEN_READ,
				array(),
				$this->buildRouteCallback(),
				$permissionCallback
			)
		);
	}

	/**
	 * Build a route-callback {@see Closure} that dispatches to
	 * {@see DiagnosticsController::read()} and adapts the returned
	 * envelope onto a {@see \WP_REST_Response} (or the pure-unit-
	 * mode array shim).
	 */
	private function buildRouteCallback(): Closure {
		return function ( mixed $request ): mixed {
			$controller = $this->resolvedController;
			if ( $controller === null ) {
				$message = 'DiagnosticsServiceProvider 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->read( $request );

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

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

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

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