<?php
/**
 * Editor REST controller.
 *
 * Phase 6c Presentation-layer controller that drives the three
 * §10.1-§10.3 editor-helper endpoints documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §10.1 `POST /affilun/v1/editor/preview` — Editor-side preview
 *     dispatch. Validates `kind` / `productIds` / `template` /
 *     `attrs`, resolves the current user id (preview requires a
 *     logged-in user per §10.1 cache-key composition), drives the
 *     canonical block-preview renderer when available (falling back
 *     to {@see PreviewProductCardUseCase}), and renders the §10.1
 *     `data.view_model` / `data.html` / `data.cache` envelope.
 *
 *   - §10.2 `GET /affilun/v1/editor/templates` — Built-in template
 *     enumeration. No request body, no validation; drives
 *     {@see ListAvailableTemplatesUseCase} and renders
 *     `data.items` per the §10.2 example.
 *
 *   - §10.3 `GET /affilun/v1/editor/providers` — Registered provider
 *     enumeration with health / capability flags. No request
 *     body, no validation; drives
 *     {@see ListAvailableProvidersUseCase} and renders
 *     `data.items` per the §10.3 example.
 *
 * Synthetic envelope key:
 *
 *   - Every method returns `Envelope::toArray() + array('_http_status' => int)`.
 *     The Phase 6c route registration layer (the slice that adds
 *     {@see \Affilun\Presentation\Rest\Routing\EditorServiceProvider})
 *     extracts and strips the {@see self::META_HTTP_STATUS} key
 *     when stamping the `WP_REST_Response` HTTP status. The key
 *     is never serialized onto the wire response — the
 *     registration layer is the sole consumer.
 *
 * Permission resolution flow (mirrors
 * {@see \Affilun\Presentation\Rest\Controller\ProductsController}):
 *
 *   1. Each method names a stable route token (one of
 *      {@see self::ROUTE_TOKEN_PREVIEW} /
 *      {@see self::ROUTE_TOKEN_TEMPLATES} /
 *      {@see self::ROUTE_TOKEN_PROVIDERS}).
 *   2. {@see CapabilityPolicy::requiredForRead()} (or
 *      `requiredForWrite()`) yields the OR-list of required caps.
 *   3. The controller probes each required cap via the injected
 *      `$capabilityProvider` callable, populates a
 *      {@see RequestContext} caps map with the held tokens, and
 *      delegates to {@see CapabilityPolicy::isAuthorized()}.
 *   4. A failed authorization check short-circuits to a 403
 *      `affilun_forbidden` error envelope; the §10.x success path
 *      runs otherwise.
 *
 * §10.1 logged-in-user requirement:
 *
 *   - The §10.1 cache-key grammar is
 *     `affilun:render:preview:{userId}:{...}:v1`. A null user id
 *     would render the cache key namespace meaningless and let
 *     anonymous traffic (which should already have been blocked
 *     by the capability check) reach the renderer. The preview
 *     method therefore short-circuits to a 401
 *     `affilun_unauthorized` envelope when the resolved user id is
 *     null, even when the capability provider returned true for
 *     a falsy reason.
 *
 * Exception mapping order in {@see self::mapException()} (mirrors
 * {@see \Affilun\Presentation\Rest\Controller\ProductsController}):
 *
 *   1. {@see ProductNotFoundException} (Application layer) → 404
 *      `affilun_product_not_found`.
 *   2. {@see ProviderException} (any §10.2 subclass) → routed
 *      through {@see ProviderExceptionMapper::map()} which yields
 *      the §15.2 envelope and HTTP status pair.
 *   3. {@see \InvalidArgumentException} → 422
 *      `affilun_validation_failed` carrying the exception message in
 *      `details.exception`. The §10.1 use case raises this for
 *      `kind === 'grid'` (Phase-7+ deferral) and for any
 *      VO-construction failure the controller-level validator
 *      did not catch.
 *   4. Catch-all {@see \Throwable} → 500 `affilun_internal_error`
 *      with the literal message `'Internal server error.'`. The
 *      original exception's class and message are NOT forwarded
 *      so the §10.1 "no internal detail leakage" posture is
 *      preserved.
 *
 * Phase-6 deferrals (documented in code, never silently dropped):
 *
 *   - §10.1 `wp_kses_post` HTML hardening: the controller
 *     forwards the rendered HTML produced by
 *     {@see PreviewProductCardUseCase} verbatim. The Phase-5
 *     templates already escape every interpolated value via
 *     `esc_html` / `esc_attr` / `esc_url`; the explicit
 *     `wp_kses_post()` wrap is deferred to a later Phase-6e /
 *     Phase-11 hardening slice (see the use case's class-level
 *     docblock for the timing).
 *
 *   - §10.1 `kind === 'grid'`: the controller passes `kind`
 *     through to the use case, which itself rejects `'grid'`
 *     with an {@see InvalidArgumentException} per the Phase-7+
 *     deferral. The 422 envelope's `details.exception` carries
 *     the deferral message verbatim so the editor surface can
 *     render an admin-facing "grid preview not yet available"
 *     hint.
 *
 *   - §10.1 preview cache: not wired in Phase-6 minimum;
 *     `cache.hit` reads `false` unconditionally per
 *     {@see PreviewResult}'s contract.
 *
 * @package Affilun\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Controller;

use Affilun\Application\Editor\UseCase\ListAvailableProvidersUseCase;
use Affilun\Application\Editor\UseCase\ListAvailableTemplatesUseCase;
use Affilun\Application\Editor\UseCase\PreviewProductCardUseCase;
use Affilun\Application\Product\Exception\ProductNotFoundException;
use Affilun\Domain\Provider\Exception\ProviderException;
use Affilun\Presentation\Rest\BlockPreviewRenderer;
use Affilun\Presentation\Rest\Permission\CapabilityPolicy;
use Affilun\Presentation\Rest\RequestContext;
use Affilun\Presentation\Rest\Response\ErrorEnvelope;
use Affilun\Presentation\Rest\Response\ProviderExceptionMapper;
use Affilun\Presentation\Rest\Response\SuccessEnvelope;
use Affilun\Presentation\Rest\Validation\RequestValidator;
use Affilun\Presentation\Rest\Validation\ValidationResult;
use InvalidArgumentException;
use Throwable;

/**
 * REST controller for the §10.1-§10.3 editor-helper endpoints.
 *
 * See the file-level docblock for the synthetic
 * {@see self::META_HTTP_STATUS} envelope key, the permission
 * resolution flow, the §10.1 logged-in-user requirement, the
 * exception mapping order, and the Phase-6 deferrals.
 *
 * ### Nonce verification (§3.1)
 *
 * Per spec §3.1, all admin / block-editor REST calls authenticate via
 * the WordPress logged-in cookie plus the canonical `wp_rest` nonce
 * header (`X-WP-Nonce`), validated automatically by the WordPress REST
 * framework. The REST `permission_callback` checks capabilities at
 * route level, and this controller repeats the same authorization
 * (§3.2 / §3.3) via the injected `CapabilityPolicy` + capability provider; nonce
 * remains the WP framework's responsibility.
 */
final class EditorController {

	/**
	 * Stable route token for §10.1 `POST /affilun/v1/editor/preview`.
	 */
	public const ROUTE_TOKEN_PREVIEW = 'editor.preview';

	/**
	 * Stable route token for §10.2 `GET /affilun/v1/editor/templates`.
	 */
	public const ROUTE_TOKEN_TEMPLATES = 'editor.templates';

	/**
	 * Stable route token for §10.3 `GET /affilun/v1/editor/providers`.
	 */
	public const ROUTE_TOKEN_PROVIDERS = 'editor.providers';

	/**
	 * Synthetic envelope key carrying the desired HTTP status.
	 *
	 * The Phase 6c route registration layer extracts and strips
	 * this key when stamping the `WP_REST_Response` status; the
	 * key is never serialized onto the wire response.
	 */
	public const META_HTTP_STATUS = '_http_status';

	/**
	 * Closed-set tokens for the §10.1 `kind` body field.
	 *
	 * The use case rejects `'grid'` itself with an
	 * {@see InvalidArgumentException} (Phase-7+ deferral); the
	 * controller validates membership in the closed set so a
	 * mistyped `'sigle'` produces a §4.4 envelope rather than the
	 * deferral message.
	 *
	 * @var list<string>
	 */
	private const PREVIEW_KIND_TOKENS = array( 'single' );

	/**
	 * §10.1 preview use case.
	 */
	private PreviewProductCardUseCase $previewUseCase;

	/**
	 * §10.2 templates enumeration use case.
	 */
	private ListAvailableTemplatesUseCase $templatesUseCase;

	/**
	 * §10.3 providers enumeration use case.
	 */
	private ListAvailableProvidersUseCase $providersUseCase;

	/**
	 * Optional preview adapter that delegates HTML generation to the same
	 * block renderers used by WordPress dynamic block rendering.
	 */
	private ?BlockPreviewRenderer $blockPreviewRenderer;

	/**
	 * §15.2 provider exception → error envelope mapper.
	 */
	private ProviderExceptionMapper $exceptionMapper;

	/**
	 * §3.2 / §3.3 capability policy.
	 */
	private CapabilityPolicy $capabilityPolicy;

	/**
	 * §17.1 request input validator.
	 */
	private RequestValidator $requestValidator;

	/**
	 * §3 request adapter (header / param / body extraction).
	 */
	private RestRequestAdapter $requestAdapter;

	/**
	 * Resolver for the current WordPress user id.
	 *
	 * Defaults to a static closure that calls
	 * {@see \get_current_user_id()} when available and returns null
	 * otherwise (so unit tests run without booting WordPress).
	 *
	 * @var callable(): ?int
	 */
	private $userIdProvider;

	/**
	 * Resolver for whether the current user holds a given capability.
	 *
	 * Defaults to a static closure that calls
	 * {@see \current_user_can()} when available and returns false
	 * otherwise (so unit tests run without booting WordPress).
	 *
	 * @var callable(string): bool
	 */
	private $capabilityProvider;

	/**
	 * Construct the controller with explicit dependency injection.
	 *
	 * The optional `$userIdProvider` / `$capabilityProvider`
	 * callables follow the established
	 * {@see \Affilun\Presentation\Rest\Controller\ProductsController}
	 * pattern: when null they fall back to WordPress-aware static
	 * closures that gracefully degrade to "anonymous / no caps"
	 * when the WordPress runtime is not loaded (the unit-test
	 * harness).
	 *
	 * @param PreviewProductCardUseCase     $previewUseCase     §10.1 preview use case.
	 * @param ListAvailableTemplatesUseCase $templatesUseCase   §10.2 templates use case.
	 * @param ListAvailableProvidersUseCase $providersUseCase   §10.3 providers use case.
	 * @param ProviderExceptionMapper       $exceptionMapper      §15.2 mapper.
	 * @param CapabilityPolicy              $capabilityPolicy     §3.2 / §3.3 policy.
	 * @param RequestValidator              $requestValidator     §17.1 validator.
	 * @param RestRequestAdapter            $requestAdapter       §3 request adapter.
	 * @param callable|null                 $userIdProvider       `(): ?int` resolver, or null to use the WP default.
	 * @param callable|null                 $capabilityProvider   `(string): bool` resolver, or null for WP default.
	 * @param BlockPreviewRenderer|null     $blockPreviewRenderer Canonical block-renderer preview adapter.
	 */
	public function __construct(
		PreviewProductCardUseCase $previewUseCase,
		ListAvailableTemplatesUseCase $templatesUseCase,
		ListAvailableProvidersUseCase $providersUseCase,
		ProviderExceptionMapper $exceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RequestValidator $requestValidator,
		RestRequestAdapter $requestAdapter,
		?callable $userIdProvider = null,
		?callable $capabilityProvider = null,
		?BlockPreviewRenderer $blockPreviewRenderer = null
	) {
		$this->previewUseCase       = $previewUseCase;
		$this->templatesUseCase     = $templatesUseCase;
		$this->providersUseCase     = $providersUseCase;
		$this->blockPreviewRenderer = $blockPreviewRenderer;
		$this->exceptionMapper      = $exceptionMapper;
		$this->capabilityPolicy     = $capabilityPolicy;
		$this->requestValidator     = $requestValidator;
		$this->requestAdapter       = $requestAdapter;

		$this->userIdProvider = $userIdProvider ?? static function (): ?int {
			if ( ! function_exists( 'get_current_user_id' ) ) {
				return null;
			}
			$id = (int) get_current_user_id();
			return $id !== 0 ? $id : null;
		};

		$this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool {
			if ( ! function_exists( 'current_user_can' ) ) {
				return false;
			}
			return (bool) current_user_can( $cap );
		};
	}

	/**
	 * §10.1 `POST /affilun/v1/editor/preview` dispatch.
	 *
	 * Validates `kind` / `productIds` / `template` / `attrs`,
	 * resolves the current user id (preview requires a logged-in
	 * user per §10.1 cache-key composition), drives the canonical
	 * block-preview renderer when available (falling back to
	 * {@see PreviewProductCardUseCase}), and renders the §10.1
	 * success envelope.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §10.1 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function preview( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_PREVIEW );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		$body       = $this->requestAdapter->jsonBody( $request );
		$kind       = array_key_exists( 'kind', $body ) ? $body['kind'] : null;
		$productIds = array_key_exists( 'productIds', $body ) ? $body['productIds'] : null;
		$template   = array_key_exists( 'template', $body ) ? $body['template'] : null;
		$attrsInput = array_key_exists( 'attrs', $body ) ? $body['attrs'] : null;

		$attrsIsMap = $attrsInput === null
			|| ( is_array( $attrsInput ) && ( $attrsInput === array() || ! array_is_list( $attrsInput ) ) );

		$result = ValidationResult::valid();
		$result = $this->requestValidator->requireOneOf( $result, 'kind', $kind, self::PREVIEW_KIND_TOKENS );
		$result = $this->requestValidator->requireArray( $result, 'product_ids', $productIds, 24 );

		if ( is_array( $productIds ) && array_is_list( $productIds ) ) {
			if ( $productIds === array() ) {
				$result = $result->withField( 'product_ids', 'Must contain at least one entry.' );
			} else {
				foreach ( $productIds as $entry ) {
					if ( ! is_string( $entry ) || trim( $entry ) === '' ) {
						$result = $result->withField( 'product_ids', 'Every entry must be a non-empty string.' );
						break;
					}
				}
			}
		}

		$result = $this->requestValidator->requireString( $result, 'template', $template, 1, 128 );

		if ( ! $attrsIsMap ) {
			$result = $result->withField( 'attrs', 'Must be an object.' );
		}

		if ( ! $result->isValid() ) {
			return $this->validationErrorResponse( $result, $ctx );
		}

		$userId = $this->currentUserId();
		if ( $userId === null ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_UNAUTHORIZED,
				'Authentication required.',
				ErrorEnvelope::STATUS_UNAUTHORIZED,
				$ctx
			);
		}

		// Validation guarantees these shapes; refine for static analysis.
		/** @var list<string> $productIdsValue */
		$productIdsValue = is_array( $productIds ) ? array_values( $productIds ) : array();
		$kindValue       = (string) $kind;
		$templateValue   = (string) $template;
		/** @var array<string, mixed> $attrsValue */
		$attrsValue = is_array( $attrsInput ) ? $attrsInput : array();

		try {
			$previewResult = $this->blockPreviewRenderer !== null
				? $this->blockPreviewRenderer->execute(
					$kindValue,
					$productIdsValue,
					$templateValue,
					$attrsValue,
					$userId
				)
				: $this->previewUseCase->execute(
					$kindValue,
					$productIdsValue,
					$templateValue,
					$attrsValue,
					$userId
				);
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		$envelope = SuccessEnvelope::fromContext(
			$previewResult->toArray(),
			$ctx
		);

		return $this->successResponse( $envelope, 200 );
	}

	/**
	 * §10.2 `GET /affilun/v1/editor/templates` dispatch.
	 *
	 * No request body, no validation. Drives
	 * {@see ListAvailableTemplatesUseCase} and renders the §10.2
	 * `data.items` envelope.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §10.2 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function templates( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_TEMPLATES );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		try {
			$items = $this->templatesUseCase->execute();
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		$envelope = SuccessEnvelope::fromContext(
			array( 'items' => $items ),
			$ctx
		);

		return $this->successResponse( $envelope, 200 );
	}

	/**
	 * §10.3 `GET /affilun/v1/editor/providers` dispatch.
	 *
	 * No request body, no validation. Drives
	 * {@see ListAvailableProvidersUseCase} and renders the §10.3
	 * `data.items` envelope.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §10.3 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function providers( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_PROVIDERS );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		try {
			$items = $this->providersUseCase->execute();
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		$envelope = SuccessEnvelope::fromContext(
			array( 'items' => $items ),
			$ctx
		);

		return $this->successResponse( $envelope, 200 );
	}

	/**
	 * Build the per-request {@see RequestContext} carrying the
	 * resolved user id and the currently-held subset of the
	 * supplied required-capability list.
	 *
	 * The caps map is populated by probing each candidate cap via
	 * the injected `$capabilityProvider`; only caps the provider
	 * confirms are entered into the map. {@see CapabilityPolicy::isAuthorized()}
	 * therefore behaves correctly against the resulting context.
	 *
	 * @param mixed        $request       WP_REST_Request, or array shim.
	 * @param list<string> $candidateCaps Caps to probe and populate.
	 */
	private function buildContext( mixed $request, array $candidateCaps ): RequestContext {
		$userId = $this->currentUserId();

		$capResolver = $this->capabilityProvider;

		$caps = array();
		foreach ( $candidateCaps as $cap ) {
			if ( $capResolver( $cap ) === true ) {
				$caps[ $cap ] = 'true';
			}
		}

		return $this->requestAdapter->buildRequestContext( $request, $userId, $caps );
	}

	/**
	 * Build a §4.3 error envelope + synthetic
	 * {@see self::META_HTTP_STATUS}.
	 *
	 * @param string               $code      §15.1 code.
	 * @param string               $message   Human-readable summary.
	 * @param int                  $status    §14.1 HTTP status.
	 * @param RequestContext       $ctx       Per-request meta.
	 * @param array<string, mixed> $details   Structured diagnostic context.
	 * @param bool                 $retryable §10.2 retryability hint.
	 *
	 * @return array<string, mixed>
	 */
	private function errorResponse(
		string $code,
		string $message,
		int $status,
		RequestContext $ctx,
		array $details = array(),
		bool $retryable = false
	): array {
		$envelope = ErrorEnvelope::fromContext( $code, $message, $status, $ctx, $details, $retryable );

		return $envelope->toArray() + array( self::META_HTTP_STATUS => $status );
	}

	/**
	 * Build a §4.4 validation error envelope + synthetic
	 * {@see self::META_HTTP_STATUS} pinned to 422.
	 *
	 * @param ValidationResult $result Accumulator carrying the
	 *                                 per-field errors.
	 * @param RequestContext   $ctx    Per-request meta.
	 *
	 * @return array<string, mixed>
	 */
	private function validationErrorResponse( ValidationResult $result, RequestContext $ctx ): array {
		$envelope = $result->toEnvelope( $ctx );

		return $envelope->toArray() + array( self::META_HTTP_STATUS => ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY );
	}

	/**
	 * Wrap a {@see SuccessEnvelope} into the response array shape
	 * the route registration layer consumes.
	 *
	 * @param SuccessEnvelope $envelope Source envelope.
	 * @param int             $status   §14.1 HTTP status to stamp.
	 *
	 * @return array<string, mixed>
	 */
	private function successResponse( SuccessEnvelope $envelope, int $status ): array {
		return $envelope->toArray() + array( self::META_HTTP_STATUS => $status );
	}

	/**
	 * Translate an arbitrary {@see Throwable} into a §4.3 envelope
	 * + synthetic {@see self::META_HTTP_STATUS}.
	 *
	 * Mapping order (mirrors
	 * {@see \Affilun\Presentation\Rest\Controller\ProductsController}):
	 *
	 *   1. {@see ProductNotFoundException} (Application layer) → 404.
	 *   2. {@see ProviderException} → routed through
	 *      {@see ProviderExceptionMapper}.
	 *   3. {@see InvalidArgumentException} → 422 with
	 *      `details.exception = $e->getMessage()`.
	 *   4. Other {@see Throwable} → 500 `affilun_internal_error` with
	 *      the literal message `'Internal server error.'`.
	 *
	 * @param Throwable      $e   Exception to translate.
	 * @param RequestContext $ctx Per-request meta.
	 *
	 * @return array<string, mixed>
	 */
	private function mapException( Throwable $e, RequestContext $ctx ): array {
		if ( $e instanceof ProductNotFoundException ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_PRODUCT_NOT_FOUND,
				$e->getMessage() !== '' ? $e->getMessage() : 'Product not found.',
				ErrorEnvelope::STATUS_NOT_FOUND,
				$ctx
			);
		}

		if ( $e instanceof ProviderException ) {
			$envelope = $this->exceptionMapper->map( $e, $ctx );

			return $envelope->toArray() + array( self::META_HTTP_STATUS => $envelope->status() );
		}

		if ( $e instanceof InvalidArgumentException ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_VALIDATION_FAILED,
				'Validation failed.',
				ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY,
				$ctx,
				array( 'exception' => $e->getMessage() )
			);
		}

		return $this->errorResponse(
			ErrorEnvelope::CODE_INTERNAL_ERROR,
			'Internal server error.',
			ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR,
			$ctx
		);
	}

	/**
	 * Resolve the current user id via the injected
	 * `$userIdProvider`, defensively coercing non-int returns to
	 * null so downstream code can rely on the `?int` shape.
	 */
	private function currentUserId(): ?int {
		$resolver = $this->userIdProvider;
		$userId   = $resolver();
		if ( $userId === null ) {
			return null;
		}

		if ( ! is_int( $userId ) ) {
			return null;
		}

		return $userId;
	}

	/**
	 * Report whether the supplied {@see RequestContext} satisfies
	 * the given OR-list of required capabilities, delegating to
	 * {@see CapabilityPolicy::isAuthorized()}.
	 *
	 * @param RequestContext $ctx      Per-request meta.
	 * @param list<string>   $required OR-list of capability tokens.
	 */
	private function isAuthorized( RequestContext $ctx, array $required ): bool {
		return $this->capabilityPolicy::isAuthorized( $ctx, $required );
	}
}
