<?php
/**
 * Sync REST controller.
 *
 * Phase 9 slice 9g Presentation-layer controller that drives the
 * §11.2 single-product sync-enqueue endpoint documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §11.2 `POST /clb/v1/sync/products/{id}` — enqueue a single-
 *     product resync. Validates the `id` route parameter (positive
 *     integer), reads the optional `correlation_id` body field,
 *     authorizes the caller against the §3.2 `sync.run` route
 *     token, and drives {@see EnqueueProductSyncUseCase}. Renders
 *     the §11.2 success envelope with HTTP 202 (Accepted) so the
 *     wire status reflects "the job is queued, not yet executed".
 *
 * Synthetic envelope key:
 *
 *   - Every method returns
 *     `Envelope::toArray() + array('_http_status' => int)`. The
 *     {@see SyncServiceProvider} 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 \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}):
 *
 *   1. The endpoint names a stable route token
 *      ({@see self::ROUTE_TOKEN_PRODUCT_SYNC} = `'sync.run'`) per
 *      §3.2.
 *   2. {@see CapabilityPolicy::requiredForWrite()} yields the
 *      OR-list of required caps (`clb_run_sync` /
 *      `manage_options`).
 *   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
 *      `clb_forbidden` error envelope; the §11.2 success path
 *      runs otherwise.
 *
 * Exception mapping order in {@see self::mapException()}:
 *
 *   1. {@see ProductNotFoundException} (Application layer) → 404
 *      `clb_product_not_found`.
 *   2. {@see \InvalidArgumentException} → 422
 *      `clb_validation_failed` carrying the exception message in
 *      `details.exception`. The §11.2 use case raises this for
 *      a non-positive product id that bypassed the route validator.
 *   3. Catch-all {@see \Throwable} → 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.
 *
 * §11.2 success envelope shape:
 *
 *   - HTTP status 202 (Accepted) — the job is queued, not yet
 *     executed.
 *   - `data` — `EnqueueProductSyncResult::toArray()` output:
 *     `array('queued' => true, 'job' => array('id', 'job_type',
 *     'status'))` (with `status` projected to the §6.0 REST
 *     publication value `'queued'`).
 *   - `meta.request_id` — §4.1 request id (provided by the
 *     {@see SuccessEnvelope}).
 *   - `meta.correlation_id` — §4.1 correlation id (provided by
 *     the {@see SuccessEnvelope} from inbound headers; preferred
 *     when present).
 *   - `meta.job_id` — convenience field carrying the persisted
 *     `clb_sync_jobs.id` so observability surfaces can deep-link
 *     without parsing `data.job.id`.
 *
 * @package CommerceLinkBuilder\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest\Controller;

use CommerceLinkBuilder\Application\Product\Exception\ProductNotFoundException;
use CommerceLinkBuilder\Application\Sync\UseCase\EnqueueProductSyncResult;
use CommerceLinkBuilder\Application\Sync\UseCase\EnqueueProductSyncUseCase;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
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 CommerceLinkBuilder\Presentation\Rest\Validation\ValidationResult;
use InvalidArgumentException;
use Throwable;

/**
 * REST controller for the §11.2 sync-enqueue endpoint.
 *
 * See the file-level docblock for the synthetic
 * {@see self::META_HTTP_STATUS} envelope key, the permission
 * resolution flow, the exception mapping order, and the §11.2
 * success envelope shape.
 *
 * ### 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 `CapabilityPolicy` + capability provider; nonce
 * remains the WP framework's responsibility.
 */
final class SyncController {

	/**
	 * Stable route token for §11.2
	 * `POST /clb/v1/sync/products/{id}`. Mapped by
	 * {@see CapabilityPolicy} to the §3.2 `sync.run` write-side
	 * OR-list (`clb_run_sync` / `manage_options`).
	 */
	public const ROUTE_TOKEN_PRODUCT_SYNC = 'sync.run';

	/**
	 * Synthetic envelope key carrying the desired HTTP status.
	 *
	 * The {@see \CommerceLinkBuilder\Presentation\Rest\SyncServiceProvider}
	 * route-callback wrapper 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';

	/**
	 * §11.2 use case driving the enqueue.
	 */
	private EnqueueProductSyncUseCase $enqueueUseCase;

	/**
	 * §15.2 provider exception → error envelope mapper. Held even
	 * though §11.2 does not raise provider exceptions itself, so
	 * the constructor matches the canonical
	 * {@see \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}
	 * shape and so future §11.x slices that DO raise provider
	 * errors (job-detail polling, retry-now) can reuse the
	 * controller without a constructor change.
	 */
	private ProviderExceptionMapper $providerExceptionMapper;

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

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

	/**
	 * §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 EnqueueProductSyncUseCase $enqueueUseCase          §11.2 use case.
	 * @param ProviderExceptionMapper   $providerExceptionMapper §15.2 mapper.
	 * @param CapabilityPolicy          $capabilityPolicy        §3.2 / §3.3 policy.
	 * @param RequestValidator          $validator               §17.1 validator.
	 * @param RestRequestAdapter        $requestAdapter          §3 request adapter.
	 * @param callable|null             $userIdProvider          `(): ?int` resolver, or null for WP default.
	 * @param callable|null             $capabilityProvider      `(string): bool` resolver, or null for WP default.
	 */
	public function __construct(
		EnqueueProductSyncUseCase $enqueueUseCase,
		ProviderExceptionMapper $providerExceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RequestValidator $validator,
		RestRequestAdapter $requestAdapter,
		?callable $userIdProvider = null,
		?callable $capabilityProvider = null
	) {
		$this->enqueueUseCase          = $enqueueUseCase;
		$this->providerExceptionMapper = $providerExceptionMapper;
		$this->capabilityPolicy        = $capabilityPolicy;
		$this->validator               = $validator;
		$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 );
		};
	}

	/**
	 * §11.2 `POST /clb/v1/sync/products/{id}` dispatch.
	 *
	 * Validates the `id` route parameter (positive integer), reads
	 * the optional `correlation_id` body field, authorizes the
	 * caller against the §3.2 `sync.run` route token, and drives
	 * {@see EnqueueProductSyncUseCase}. Renders the §11.2 success
	 * envelope with HTTP 202 on success.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §11.2 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function enqueue( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_PRODUCT_SYNC );
		$ctx      = $this->buildContext( $request, $required );

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

		$id = $this->requestAdapter->paramInt( $request, 'id' );

		$result = ValidationResult::valid();
		$result = $this->validator->requireInt( $result, 'id', $id, 1, PHP_INT_MAX );

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

		$correlationId = null;
		$body          = $this->requestAdapter->jsonBody( $request );
		if ( array_key_exists( 'correlation_id', $body ) ) {
			$rawCorr = $body['correlation_id'];
			if ( is_string( $rawCorr ) && trim( $rawCorr ) !== '' ) {
				$correlationId = trim( $rawCorr );
			}
		}

		try {
			$enqueueResult = $this->enqueueUseCase->execute( (int) $id, $correlationId );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		return $this->successResponse( $enqueueResult, $ctx );
	}

	/**
	 * Build the per-request {@see RequestContext} carrying the
	 * resolved user id and 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 {
		$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 the §11.2 success envelope (HTTP 202 Accepted).
	 *
	 * Adds the convenience `meta.job_id` field carrying the
	 * persisted `clb_sync_jobs.id` so observability surfaces can
	 * deep-link without parsing `data.job.id`.
	 *
	 * @param EnqueueProductSyncResult $result Result of the enqueue.
	 * @param RequestContext           $ctx    Per-request meta.
	 *
	 * @return array<string, mixed>
	 */
	private function successResponse(
		EnqueueProductSyncResult $result,
		RequestContext $ctx
	): array {
		$envelope = SuccessEnvelope::fromContext(
			$result->toArray(),
			$ctx,
			array( 'job_id' => (int) $result->job()->id() )
		);

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

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

	/**
	 * Translate an arbitrary {@see Throwable} into a §4.3 envelope
	 * + synthetic {@see self::META_HTTP_STATUS}.
	 *
	 * Mapping order:
	 *
	 *   1. {@see ProductNotFoundException} → 404.
	 *   2. {@see ProviderException} → routed through
	 *      {@see ProviderExceptionMapper::map()}.
	 *   3. {@see InvalidArgumentException} → 422 with
	 *      `details.exception = $e->getMessage()`.
	 *   4. Other {@see Throwable} → 500 `clb_internal_error` with
	 *      the literal message `'Internal server error.'`.
	 *
	 * The {@see ProviderExceptionMapper} dependency is held for
	 * symmetry with the canonical
	 * {@see \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}
	 * shape; §11.2 itself is unlikely to raise provider exceptions
	 * but future slices may.
	 *
	 * @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->providerExceptionMapper->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 );
	}
}
