<?php
/**
 * Settings REST controller.
 *
 * Phase-8 slice 8b Presentation-layer controller that drives
 * the §13.1 `GET /clb/v1/settings/public` endpoint documented
 * in `specifications/rest-api-spec.md`. The endpoint returns
 * ONLY public-safe configuration values — API keys, secrets,
 * encrypted credentials, internal diagnostics, the §13.9 debug
 * mode flag, the §13.7 sync settings, and the §13.8 cache
 * settings are all explicitly excluded by
 * {@see \CommerceLinkBuilder\Presentation\Rest\Mapper\PublicSettingsMapper}.
 *
 * Permission policy:
 *
 *   - §3.2 read-side mapping for the `'settings.read.public'`
 *     route token. Per `specifications/rest-api-spec.md` §13.1
 *     and `CapabilityPolicy::READ_MAP`, the endpoint requires
 *     `edit_posts` (or any future canonical clb_* capability
 *     the policy adds to the OR-list).
 *
 *   - The §3.2 / §3.3 capability check runs inside this
 *     controller via the injected {@see CapabilityPolicy} +
 *     capability provider so a denied caller surfaces as the
 *     canonical §4.3 `clb_forbidden` envelope rather than the
 *     generic WordPress 401/403 page.
 *
 * Synthetic envelope key:
 *
 *   - {@see self::readPublic()} returns
 *     `Envelope::toArray() + array('_http_status' => int)`.
 *     The Phase-8 route registration layer
 *     ({@see \CommerceLinkBuilder\Presentation\Rest\SettingsServiceProvider})
 *     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.
 *
 * Exception mapping:
 *
 *   - Any {@see \Throwable} surfacing out of the use case is
 *     translated to a §4.3 500 `clb_internal_error` envelope
 *     with the literal message `'Internal server error.'`.
 *     The original exception's class and message are NOT
 *     forwarded so the §13.1 "no internal detail leakage"
 *     posture is preserved (per
 *     `specifications/security-and-compliance.md` §6.4).
 *
 * ### 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 before
 * `permission_callback` runs. This controller does NOT
 * re-validate the nonce in code — duplicate validation would
 * diverge from WP's canonical lifecycle and is forbidden by
 * §17.3. Capability authorization (§3.2 / §3.3) runs inside
 * this controller via the injected {@see CapabilityPolicy} +
 * capability provider; nonce remains the WP framework's
 * responsibility.
 *
 * @package CommerceLinkBuilder\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest\Controller;

use CommerceLinkBuilder\Application\Settings\UseCase\GetPublicSettingsUseCase;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
use CommerceLinkBuilder\Presentation\Rest\Mapper\PublicSettingsMapper;
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 CommerceLinkBuilder\Presentation\Rest\Validation\RequestValidator;
use Throwable;

/**
 * REST controller for the §13.1 `GET /clb/v1/settings/public`
 * endpoint.
 *
 * See the file-level docblock for the §13.1 public-safe data
 * contract, the §3.2 capability policy, the synthetic
 * {@see self::META_HTTP_STATUS} envelope key, and the
 * exception mapping posture.
 */
final class SettingsController {

	/**
	 * Stable route token for §13.1
	 * `GET /clb/v1/settings/public`.
	 */
	public const ROUTE_TOKEN_PUBLIC_GET = 'settings.read.public';

	/**
	 * Synthetic envelope key carrying the desired HTTP status.
	 *
	 * The Phase-8 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';

	/**
	 * §13.1 public-settings use case.
	 */
	private GetPublicSettingsUseCase $getPublicSettingsUseCase;

	/**
	 * §13.1 settings → public response mapper.
	 */
	private PublicSettingsMapper $publicSettingsMapper;

	/**
	 * §15.2 provider exception → error envelope mapper.
	 *
	 * Held for symmetry with the other Phase-6 / Phase-7
	 * controllers; the §13.1 endpoint never invokes a provider
	 * directly, but a future slice may extend the use case to
	 * resolve provider health, and the {@see ProviderException}
	 * branch in {@see self::mapException()} routes through this
	 * mapper so the wiring seam stays uniform.
	 */
	private ProviderExceptionMapper $exceptionMapper;

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

	/**
	 * §17.1 request input validator.
	 *
	 * Held for parity with the other REST controllers; the
	 * §13.1 endpoint has no body and no path params, so the
	 * validator is unused on the current code path. A future
	 * slice that adds query-string filters can read it without
	 * widening the constructor signature.
	 *
	 * @phpstan-ignore property.onlyWritten
	 */
	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 \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}
	 * 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 GetPublicSettingsUseCase $getPublicSettingsUseCase §13.1 use case.
	 * @param PublicSettingsMapper     $publicSettingsMapper     §13.1 response mapper.
	 * @param ProviderExceptionMapper  $exceptionMapper          §15.2 mapper (held for parity).
	 * @param CapabilityPolicy         $capabilityPolicy         §3.2 / §3.3 policy.
	 * @param RequestValidator         $requestValidator         §17.1 validator (held for parity).
	 * @param RestRequestAdapter       $requestAdapter           §3 request adapter.
	 * @param callable|null            $userIdProvider           `(): ?int` resolver, or null for the WP default.
	 * @param callable|null            $capabilityProvider       `(string): bool` resolver, or null for WP default.
	 */
	public function __construct(
		GetPublicSettingsUseCase $getPublicSettingsUseCase,
		PublicSettingsMapper $publicSettingsMapper,
		ProviderExceptionMapper $exceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RequestValidator $requestValidator,
		RestRequestAdapter $requestAdapter,
		?callable $userIdProvider = null,
		?callable $capabilityProvider = null
	) {
		$this->getPublicSettingsUseCase = $getPublicSettingsUseCase;
		$this->publicSettingsMapper     = $publicSettingsMapper;
		$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 );
		};
	}

	/**
	 * §13.1 `GET /clb/v1/settings/public` dispatch.
	 *
	 * No request body, no path params. Drives
	 * {@see GetPublicSettingsUseCase}, runs the result through
	 * {@see PublicSettingsMapper}, and returns the §4 success
	 * envelope.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §13.1 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function readPublic( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_PUBLIC_GET );
		$ctx      = $this->buildContext( $request, $required );

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

		try {
			$settings = $this->getPublicSettingsUseCase->execute();
			$data     = $this->publicSettingsMapper->map( $settings );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		$envelope = SuccessEnvelope::fromContext( $data, $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 );
	}

	/**
	 * 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}.
	 *
	 * Per §13.1's "no internal detail leakage" posture the
	 * unknown-exception path is mapped to a §4.3 500
	 * `clb_internal_error` envelope with the literal message
	 * `'Internal server error.'`. The original exception's
	 * class and message are NOT forwarded.
	 *
	 * The unused {@see ProviderExceptionMapper} dependency is
	 * held in the constructor for symmetry with the other
	 * Phase-6 / Phase-7 controllers; future slices that grow
	 * the use case to resolve provider health will route the
	 * §15.2 provider exceptions through the mapper here.
	 *
	 * @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 ProviderException ) {
			$envelope = $this->exceptionMapper->map( $e, $ctx );

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

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