<?php
/**
 * Diagnostics REST controller.
 *
 * Phase 11 slice 11e-B Presentation-layer controller that drives the
 * §12.1 `GET /clb/v1/diagnostics` REST surface documented in
 * `specifications/rest-api-spec.md`.
 *
 * Synthetic envelope key:
 *
 *   - The controller method returns
 *     `Envelope::toArray() + array('_http_status' => int)`. The
 *     {@see \CommerceLinkBuilder\Presentation\Rest\DiagnosticsServiceProvider}
 *     route-callback wrapper 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.
 *
 * Permission resolution flow (mirrors {@see LogsController}):
 *
 *   1. The endpoint names the §3.2 `'diagnostics.read'` route
 *      token, resolved by {@see CapabilityPolicy::requiredForRead()}
 *      to the OR-list `clb_view_diagnostics` / `manage_options`.
 *   2. 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()}.
 *   3. A failed authorization check short-circuits to a 403
 *      `clb_forbidden` error envelope; the §12.1 success path runs
 *      otherwise.
 *
 * Exception mapping:
 *
 *   - Any {@see \Throwable} raised by the use case is translated to
 *     500 `clb_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.
 *
 * @package CommerceLinkBuilder\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest\Controller;

use CommerceLinkBuilder\Application\Diagnostics\UseCase\GetDiagnosticsUseCase;
use CommerceLinkBuilder\Presentation\Rest\Permission\CapabilityPolicy;
use CommerceLinkBuilder\Presentation\Rest\RequestContext;
use CommerceLinkBuilder\Presentation\Rest\Response\ErrorEnvelope;
use CommerceLinkBuilder\Presentation\Rest\Response\ProviderExceptionMapper;
use CommerceLinkBuilder\Presentation\Rest\Response\SuccessEnvelope;
use Throwable;

/**
 * REST controller for the §12.1 diagnostics endpoint.
 *
 * See the file-level docblock for the synthetic
 * {@see self::META_HTTP_STATUS} envelope key, the permission
 * resolution flow, and the exception mapping policy.
 */
final class DiagnosticsController {

	/**
	 * Stable route token for §12.1 `GET /clb/v1/diagnostics`.
	 * Mapped by {@see CapabilityPolicy} to the §3.2
	 * `diagnostics.read` read-side OR-list
	 * (`clb_view_diagnostics` / `manage_options`).
	 */
	public const ROUTE_TOKEN_READ = 'diagnostics.read';

	/**
	 * Synthetic envelope key carrying the desired HTTP status.
	 */
	public const META_HTTP_STATUS = '_http_status';

	/**
	 * §12.1 use case.
	 */
	private GetDiagnosticsUseCase $useCase;

	/**
	 * §15.2 provider exception → error envelope mapper. Held even
	 * though §12.1 does not raise provider exceptions itself, so
	 * the constructor matches the canonical
	 * {@see \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}
	 * shape.
	 *
	 * @phpstan-ignore property.onlyWritten
	 */
	private ProviderExceptionMapper $exceptionMapper;

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

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

	/**
	 * 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.
	 *
	 * @param GetDiagnosticsUseCase   $useCase            §12.1 use case.
	 * @param ProviderExceptionMapper $exceptionMapper    §15.2 mapper.
	 * @param CapabilityPolicy        $capabilityPolicy   §3.2 / §3.3 policy.
	 * @param RestRequestAdapter      $requestAdapter     §3 request adapter.
	 * @param callable|null           $capabilityProvider `(string): bool` resolver, or null for WP default.
	 */
	public function __construct(
		GetDiagnosticsUseCase $useCase,
		ProviderExceptionMapper $exceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RestRequestAdapter $requestAdapter,
		?callable $capabilityProvider = null
	) {
		$this->useCase          = $useCase;
		$this->exceptionMapper  = $exceptionMapper;
		$this->capabilityPolicy = $capabilityPolicy;
		$this->requestAdapter   = $requestAdapter;

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

	/**
	 * §12.1 `GET /clb/v1/diagnostics` dispatch.
	 *
	 * Authorizes the caller against the §3.2 `diagnostics.read`
	 * route token and drives {@see GetDiagnosticsUseCase}. Renders
	 * the §12.1 success envelope with HTTP 200.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §12.1 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function read( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_READ );
		$ctx      = $this->buildContext( $request, $required );

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

		try {
			$result = $this->useCase->execute();
		} catch ( Throwable $e ) {
			unset( $e );
			return $this->errorResponse(
				ErrorEnvelope::CODE_INTERNAL_ERROR,
				'Internal server error.',
				ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR,
				$ctx
			);
		}

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

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

	/**
	 * Build the per-request {@see RequestContext} carrying the
	 * currently-held subset of the supplied required-capability
	 * list.
	 *
	 * @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 {
		$capResolver = $this->capabilityProvider;

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

		return $this->requestAdapter->buildRequestContext( $request, null, $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 );
	}

	/**
	 * 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 );
	}
}
