<?php
/**
 * Sync-jobs REST service provider.
 *
 * Phase 11 slice 11e-B Bootstrap-layer service provider that wires
 * the §11.3 / §11.4 sync-jobs REST endpoints documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §11.3 `GET /affilun/v1/sync/jobs`      → {@see SyncJobsController::list()}.
 *   - §11.4 `GET /affilun/v1/sync/jobs/{id}` → {@see SyncJobsController::detail()}.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_LIST_USE_CASE} — §11.3 use case.
 *   - {@see self::BINDING_GET_USE_CASE}  — §11.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 the
 * {@see PersistenceServiceProvider} (publishes
 * {@see PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY})
 * and the {@see RestServiceProvider} (publishes
 * {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}) before this
 * one.
 *
 * @package Affilun\Presentation\Rest
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest;

use Affilun\Application\SyncJobs\UseCase\GetSyncJobUseCase;
use Affilun\Application\SyncJobs\UseCase\ListSyncJobsUseCase;
use Affilun\Bootstrap\HookRegistrar;
use Affilun\Bootstrap\ServiceProvider;
use Affilun\Bootstrap\ServiceProviderRegistry;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Infrastructure\Persistence\SyncJobRepository;
use Affilun\Presentation\Rest\Controller\RestRequestAdapter;
use Affilun\Presentation\Rest\Controller\SyncJobsController;
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 LogicException;

/**
 * Binds the §11.3 / §11.4 use cases plus the composing
 * {@see SyncJobsController}, then appends two
 * {@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 and the three registry keys this provider exposes.
 */
final class SyncJobsServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the §11.3
	 * {@see ListSyncJobsUseCase} is bound.
	 */
	public const BINDING_LIST_USE_CASE = 'affilun.rest.sync_jobs.list_use_case';

	/**
	 * Registry key under which the §11.4
	 * {@see GetSyncJobUseCase} is bound.
	 */
	public const BINDING_GET_USE_CASE = 'affilun.rest.sync_jobs.get_use_case';

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

	/**
	 * §11.3 list use case override.
	 */
	private ?ListSyncJobsUseCase $listUseCaseOverride;

	/**
	 * §11.4 detail use case override.
	 */
	private ?GetSyncJobUseCase $getUseCaseOverride;

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

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

	/**
	 * Construct a sync-jobs 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 ListSyncJobsUseCase|null $listUseCaseOverride §11.3 use case override.
	 * @param GetSyncJobUseCase|null   $getUseCaseOverride  §11.4 use case override.
	 * @param SyncJobsController|null  $controllerOverride  Composed controller override.
	 */
	public function __construct(
		?ListSyncJobsUseCase $listUseCaseOverride = null,
		?GetSyncJobUseCase $getUseCaseOverride = null,
		?SyncJobsController $controllerOverride = null
	) {
		$this->listUseCaseOverride = $listUseCaseOverride;
		$this->getUseCaseOverride  = $getUseCaseOverride;
		$this->controllerOverride  = $controllerOverride;
	}

	/**
	 * Bind the §11.3 / §11.4 use cases plus the composed
	 * {@see SyncJobsController}, then append two
	 * {@see RouteDefinition} values 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 {
		$jobs = $this->resolveSyncJobRepository( $registry );

		$listUseCase = $this->listUseCaseOverride ?? new ListSyncJobsUseCase( $jobs );
		$getUseCase  = $this->getUseCaseOverride ?? new GetSyncJobUseCase( $jobs );

		$controller = $this->controllerOverride ?? new SyncJobsController(
			$listUseCase,
			$getUseCase,
			new ProviderExceptionMapper(),
			new CapabilityPolicy(),
			new RequestValidator(),
			new RestRequestAdapter()
		);

		$this->resolvedController = $controller;

		$registry->bind( self::BINDING_LIST_USE_CASE, $listUseCase );
		$registry->bind( self::BINDING_GET_USE_CASE, $getUseCase );
		$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 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 = 'SyncJobsServiceProvider 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(
				'SyncJobsServiceProvider 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 {@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 = 'SyncJobsServiceProvider 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(
				'SyncJobsServiceProvider 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 two §11.3 / §11.4 {@see RouteDefinition} values
	 * 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,
				'/sync/jobs',
				RouteDefinition::METHOD_GET,
				SyncJobsController::ROUTE_TOKEN_LIST,
				array(),
				$this->buildRouteCallback( 'list' ),
				$permissionCallback
			)
		);

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/sync/jobs/(?P<id>[0-9]+)',
				RouteDefinition::METHOD_GET,
				SyncJobsController::ROUTE_TOKEN_DETAIL,
				array(),
				$this->buildRouteCallback( 'detail' ),
				$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).
	 *
	 * @param string $method One of `'list'`, `'detail'`.
	 */
	private function buildRouteCallback( string $method ): Closure {
		return function ( mixed $request ) use ( $method ): mixed {
			$controller = $this->resolvedController;
			if ( $controller === null ) {
				$message = 'SyncJobsServiceProvider 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 ) {
				'list'   => $controller->list( $request ),
				'detail' => $controller->detail( $request ),
				default  => $this->raiseUnknownMethod( $method ),
			};

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

			unset( $envelope[ SyncJobsController::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.
	 *
	 * @param string $method Offending method name.
	 *
	 * @return never
	 *
	 * @throws LogicException Always.
	 */
	private function raiseUnknownMethod( string $method ): array {
		$message = sprintf(
			'SyncJobsServiceProvider route callback received unknown method "%s".',
			$method
		);
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw new LogicException( $message );
	}
}
