<?php
/**
 * Sync-jobs REST controller.
 *
 * Phase 11 slice 11e-B Presentation-layer controller that drives the
 * §11.3 / §11.4 sync-jobs REST surfaces documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §11.3 `GET /affilun/v1/sync/jobs`       → {@see self::list()}.
 *   - §11.4 `GET /affilun/v1/sync/jobs/{id}`  → {@see self::detail()}.
 *
 * Synthetic envelope key:
 *
 *   - Every method returns
 *     `Envelope::toArray() + array('_http_status' => int)`. The
 *     {@see \Affilun\Presentation\Rest\SyncJobsServiceProvider}
 *     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.
 *
 * Wire ⇔ DB status mapping placement:
 *
 *   The §11.3 surface accepts a wire-vocabulary `status` query
 *   parameter (`queued|running|completed|failed|cancelled`) but the
 *   persistence layer's
 *   {@see \Affilun\Infrastructure\Persistence\SyncJobListQuery}
 *   speaks DB-vocabulary
 *   (`pending|running|completed|failed|cancelled`). Per §6.0 the only
 *   spelling difference is wire `queued` ⇔ DB `pending`. This
 *   controller is the inbound seam where wire `queued` is rewritten
 *   to DB `pending` BEFORE constructing the query VO; the use case
 *   then performs the inverse DB → wire substitution on each
 *   projected row. Every other wire status passes through unchanged
 *   so the persistence layer can validate the closed set without
 *   re-reasoning about wire spelling.
 *
 * Permission resolution flow (mirrors {@see LogsController}):
 *
 *   1. Both endpoints share the §3.2 `'sync.jobs.read'` route token,
 *     resolved by {@see CapabilityPolicy::requiredForRead()} to the
 *     OR-list `affilun_run_sync` / `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
 *     `affilun_forbidden` error envelope; the §11.3 / §11.4 success
 *     paths run otherwise.
 *
 * Exception mapping order in {@see self::mapException()}:
 *
 *   1. {@see SyncJobNotFoundException} → 404
 *      `affilun_sync_job_not_found`.
 *   2. {@see \InvalidArgumentException} → 422 `affilun_validation_failed`
 *      carrying the exception message in `details.exception`.
 *   3. 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.
 *
 * @package Affilun\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Controller;

use Affilun\Application\SyncJobs\Exception\SyncJobNotFoundException;
use Affilun\Application\SyncJobs\UseCase\GetSyncJobUseCase;
use Affilun\Application\SyncJobs\UseCase\ListSyncJobsUseCase;
use Affilun\Infrastructure\Persistence\SyncJobListQuery;
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 §11.3 / §11.4 sync-jobs endpoints.
 *
 * See the file-level docblock for the synthetic
 * {@see self::META_HTTP_STATUS} envelope key, the wire ⇔ DB status
 * mapping placement, the permission resolution flow, and the
 * exception mapping order.
 */
final class SyncJobsController {

	/**
	 * Stable route token for §11.3 `GET /affilun/v1/sync/jobs`. Mapped
	 * by {@see CapabilityPolicy} to the §3.2 `sync.jobs.read`
	 * read-side OR-list (`affilun_run_sync` / `manage_options`).
	 */
	public const ROUTE_TOKEN_LIST = 'sync.jobs.read';

	/**
	 * Stable route token for §11.4 `GET /affilun/v1/sync/jobs/{id}`.
	 * Shares the `'sync.jobs.read'` token with the list endpoint by
	 * design — both surfaces are admin-tier sync observability views
	 * and the §3.2 read-side mapping does not split them.
	 */
	public const ROUTE_TOKEN_DETAIL = 'sync.jobs.read';

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

	/**
	 * Wire-vocabulary status corresponding to DB `pending` per §6.0.
	 */
	private const WIRE_STATUS_QUEUED = 'queued';

	/**
	 * DB-vocabulary status corresponding to wire `queued` per §6.0.
	 */
	private const DB_STATUS_PENDING = 'pending';

	/**
	 * §11.3 list use case.
	 */
	private ListSyncJobsUseCase $listUseCase;

	/**
	 * §11.4 detail use case.
	 */
	private GetSyncJobUseCase $getUseCase;

	/**
	 * §15.2 provider exception → error envelope mapper. Held even
	 * though §11.3 / §11.4 do not raise provider exceptions
	 * themselves, so the constructor matches the canonical
	 * {@see \Affilun\Presentation\Rest\Controller\EditorController}
	 * shape.
	 *
	 * @phpstan-ignore property.onlyWritten
	 */
	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 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 ListSyncJobsUseCase     $listUseCase        §11.3 use case.
	 * @param GetSyncJobUseCase       $getUseCase         §11.4 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           $capabilityProvider `(string): bool` resolver, or null for WP default.
	 */
	public function __construct(
		ListSyncJobsUseCase $listUseCase,
		GetSyncJobUseCase $getUseCase,
		ProviderExceptionMapper $exceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RequestValidator $requestValidator,
		RestRequestAdapter $requestAdapter,
		?callable $capabilityProvider = null
	) {
		$this->listUseCase      = $listUseCase;
		$this->getUseCase       = $getUseCase;
		$this->exceptionMapper  = $exceptionMapper;
		$this->capabilityPolicy = $capabilityPolicy;
		$this->requestValidator = $requestValidator;
		$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 );
		};
	}

	/**
	 * §11.3 `GET /affilun/v1/sync/jobs` dispatch.
	 *
	 * Reads the wire-vocabulary `status` filter, the `job_type`
	 * filter, and the pagination axes (`page`, `per_page`),
	 * substitutes wire `queued` → DB `pending` per §6.0, builds a
	 * {@see SyncJobListQuery}, and drives
	 * {@see ListSyncJobsUseCase}.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §11.3 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function list( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_LIST );
		$ctx      = $this->buildContext( $request, $required );

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

		$status  = $this->requestAdapter->paramString( $request, 'status' );
		$jobType = $this->requestAdapter->paramString( $request, 'job_type' );
		$page    = $this->requestAdapter->paramInt( $request, 'page', 1 );
		$perPage = $this->requestAdapter->paramInt(
			$request,
			'per_page',
			SyncJobListQuery::DEFAULT_PER_PAGE
		);

		// §6.0 wire → DB status substitution: wire `queued` becomes DB
		// `pending` before reaching the persistence-layer query VO.
		// Every other wire status passes through unchanged so the
		// query VO can reject off-vocabulary values loudly.
		if ( $status === self::WIRE_STATUS_QUEUED ) {
			$status = self::DB_STATUS_PENDING;
		}

		$result = ValidationResult::valid();
		$result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX );
		$result = $this->requestValidator->requireInt(
			$result,
			'per_page',
			$perPage,
			1,
			SyncJobListQuery::MAX_PER_PAGE
		);

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

		try {
			$query = new SyncJobListQuery(
				$status,
				$jobType,
				(int) $page,
				(int) $perPage
			);
		} catch ( InvalidArgumentException $e ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_VALIDATION_FAILED,
				'Validation failed.',
				ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY,
				$ctx,
				array( 'exception' => $e->getMessage() )
			);
		}

		try {
			$listResult = $this->listUseCase->execute( $query );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

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

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

	/**
	 * §11.4 `GET /affilun/v1/sync/jobs/{id}` dispatch.
	 *
	 * Validates the `id` route parameter as a positive integer,
	 * drives {@see GetSyncJobUseCase}, and renders the §11.4
	 * success envelope with HTTP 200.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §11.4 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function detail( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_DETAIL );
		$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->requestValidator->requireInt( $result, 'id', $id, 1, PHP_INT_MAX );

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

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

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

	/**
	 * 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 SyncJobNotFoundException} → 404
	 *      `affilun_sync_job_not_found`.
	 *   2. {@see InvalidArgumentException} → 422 with
	 *      `details.exception = $e->getMessage()`.
	 *   3. Other {@see Throwable} → 500 `affilun_internal_error` with
	 *      the literal message `'Internal server error.'`.
	 *
	 * The {@see ProviderExceptionMapper} dependency is held for
	 * symmetry with the canonical
	 * {@see \Affilun\Presentation\Rest\Controller\EditorController}
	 * shape; §11.3 / §11.4 themselves do not raise provider
	 * exceptions.
	 *
	 * @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 SyncJobNotFoundException ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_SYNC_JOB_NOT_FOUND,
				$e->getMessage() !== '' ? $e->getMessage() : 'Sync job not found.',
				ErrorEnvelope::STATUS_NOT_FOUND,
				$ctx,
				array( 'id' => $e->id() )
			);
		}

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

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