<?php
/**
 * Audit REST service provider.
 *
 * Phase 2 (KI-006) Bootstrap-layer service provider that wires
 * the §12.4 audit-events REST endpoint documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §12.4 `GET /clb/v1/audit/events` → {@see AuditController::list()}.
 *
 * The §12.5 `GET /clb/v1/audit/events/summary` follow-up surface
 * is intentionally out of scope for the KI-006 closeout slice and
 * is tracked as a separate issue; once it lands, this provider is
 * the natural place to wire the additional route definition.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_LIST_USE_CASE} — §12.4 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 the
 * {@see PersistenceServiceProvider} (publishes
 * {@see PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY})
 * and the {@see RestServiceProvider} (publishes
 * {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}) before
 * this one.
 *
 * @package CommerceLinkBuilder\Presentation\Rest
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest;

use CommerceLinkBuilder\Application\Audit\UseCase\GetAuditEventsSummaryUseCase;
use CommerceLinkBuilder\Application\Audit\UseCase\ListAuditEventsUseCase;
use CommerceLinkBuilder\Bootstrap\HookRegistrar;
use CommerceLinkBuilder\Bootstrap\ServiceProvider;
use CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry;
use CommerceLinkBuilder\Infrastructure\Persistence\AuditEventRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider;
use CommerceLinkBuilder\Presentation\Rest\Controller\AuditController;
use CommerceLinkBuilder\Presentation\Rest\Controller\RestRequestAdapter;
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 §12.4 use case plus the composing
 * {@see AuditController}, 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 and the two registry keys this provider exposes.
 */
final class AuditServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the §12.4
	 * {@see ListAuditEventsUseCase} is bound.
	 */
	public const BINDING_LIST_USE_CASE = 'clb.rest.audit.list_use_case';

	/**
	 * Registry key under which the §12.5
	 * {@see GetAuditEventsSummaryUseCase} is bound.
	 */
	public const BINDING_SUMMARY_USE_CASE = 'clb.rest.audit.summary_use_case';

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

	/**
	 * §12.4 list use case override.
	 */
	private ?ListAuditEventsUseCase $listUseCaseOverride;

	/**
	 * §12.5 summary use case override.
	 */
	private ?GetAuditEventsSummaryUseCase $summaryUseCaseOverride;

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

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

	/**
	 * Construct an audit 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 ListAuditEventsUseCase|null       $listUseCaseOverride    §12.4 use case override.
	 * @param AuditController|null              $controllerOverride     Composed controller override.
	 * @param GetAuditEventsSummaryUseCase|null $summaryUseCaseOverride §12.5 use case override.
	 */
	public function __construct(
		?ListAuditEventsUseCase $listUseCaseOverride = null,
		?AuditController $controllerOverride = null,
		?GetAuditEventsSummaryUseCase $summaryUseCaseOverride = null
	) {
		$this->listUseCaseOverride    = $listUseCaseOverride;
		$this->controllerOverride     = $controllerOverride;
		$this->summaryUseCaseOverride = $summaryUseCaseOverride;
	}

	/**
	 * Bind the §12.4 use case plus the composed
	 * {@see AuditController}, then append one
	 * {@see RouteDefinition} value to the bound
	 * {@see RouteRegistrar}.
	 *
	 * Resolution order:
	 *
	 *   1. Resolve {@see AuditEventRepository} from
	 *      {@see PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY}.
	 *      Missing binding raises {@see LogicException} so a
	 *      misordered bootstrap fails loudly here rather than at
	 *      the first REST request.
	 *
	 *   2. Build (or accept the override for) the §12.4
	 *      {@see ListAuditEventsUseCase} against the resolved
	 *      repository.
	 *
	 *   3. Build (or accept the override for) the
	 *      {@see AuditController} composed from the use case plus
	 *      a fresh {@see ProviderExceptionMapper} /
	 *      {@see CapabilityPolicy} / {@see RequestValidator} /
	 *      {@see RestRequestAdapter}.
	 *
	 *   4. 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.
	 *
	 *   5. Resolve {@see RouteRegistrar} from
	 *      {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR} and
	 *      append the §12.4 {@see RouteDefinition}.
	 *
	 * @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 {
		$events = $this->resolveAuditEventRepository( $registry );

		$listUseCase    = $this->listUseCaseOverride ?? new ListAuditEventsUseCase( $events );
		$summaryUseCase = $this->summaryUseCaseOverride ?? new GetAuditEventsSummaryUseCase( $events );

		$controller = $this->controllerOverride ?? new AuditController(
			$listUseCase,
			new ProviderExceptionMapper(),
			new CapabilityPolicy(),
			new RequestValidator(),
			new RestRequestAdapter(),
			null,
			$summaryUseCase
		);

		$this->resolvedController = $controller;

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

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

		return $events;
	}

	/**
	 * 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 = 'AuditServiceProvider 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(
				'AuditServiceProvider 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.4 list and §12.5 summary
	 * {@see RouteDefinition} values to the supplied registrar.
	 *
	 * Both routes share the same permission callback (which
	 * always allows; the actual authorization is enforced inside
	 * the controller via the injected {@see CapabilityPolicy} so
	 * a denied caller surfaces the canonical §4.3 `clb_forbidden`
	 * envelope rather than the generic WordPress 401/403 page).
	 *
	 * @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,
				'/audit/events',
				RouteDefinition::METHOD_GET,
				AuditController::ROUTE_TOKEN_LIST,
				array(),
				$this->buildRouteCallback(),
				$permissionCallback
			)
		);

		$registrar->add(
			new RouteDefinition(
				RestNamespace::FULL,
				'/audit/events/summary',
				RouteDefinition::METHOD_GET,
				AuditController::ROUTE_TOKEN_SUMMARY,
				array(),
				$this->buildSummaryRouteCallback(),
				$permissionCallback
			)
		);
	}

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

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

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

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

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

	/**
	 * Build a route-callback {@see Closure} that dispatches to
	 * {@see AuditController::summary()} and adapts the returned
	 * envelope onto a {@see \WP_REST_Response} (or the
	 * pure-unit-mode array shim).
	 *
	 * Mirrors {@see self::buildRouteCallback()} verbatim except
	 * for the dispatched method, so the §12.5 summary surface
	 * inherits the same HTTP-status synthesis and pure-unit-mode
	 * fallback the §12.4 list surface uses.
	 */
	private function buildSummaryRouteCallback(): Closure {
		return function ( mixed $request ): mixed {
			$controller = $this->resolvedController;
			if ( $controller === null ) {
				$message = 'AuditServiceProvider summary 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->summary( $request );

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

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

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

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