<?php
/**
 * Logs REST controller.
 *
 * Phase 11 slice 11d Presentation-layer controller that drives the
 * §12.2 / §12.3 logs REST surfaces documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §12.2 `GET /clb/v1/logs`         → {@see self::list()}.
 *   - §12.3 `GET /clb/v1/logs/summary` → {@see self::summary()}.
 *
 * Synthetic envelope key:
 *
 *   - Every method returns
 *     `Envelope::toArray() + array('_http_status' => int)`. The
 *     {@see \CommerceLinkBuilder\Presentation\Rest\LogsServiceProvider}
 *     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 the canonical
 * {@see \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}
 * pattern):
 *
 *   1. Both endpoints share the §3.2 `'logs.read'` route token,
 *     resolved by {@see CapabilityPolicy::requiredForRead()} to
 *     the OR-list `clb_view_logs` / `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.2 / §12.3 success
 *     paths run otherwise.
 *
 * Exception mapping order in {@see self::mapException()}:
 *
 *   1. {@see \InvalidArgumentException} → 422 `clb_validation_failed`
 *     carrying the exception message in `details.exception`. The
 *     §12.2 query-param parsing path raises this (via
 *     {@see LogListQuery}) when a column-level filter is off-
 *     whitelist or out of range; the §12.3 path raises this when
 *     `recent_limit` is out of the [1, 50] range.
 *   2. 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
 *     "no internal detail leakage" posture is preserved.
 *
 * @package CommerceLinkBuilder\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest\Controller;

use CommerceLinkBuilder\Application\Logs\UseCase\GetLogsSummaryUseCase;
use CommerceLinkBuilder\Application\Logs\UseCase\ListLogsUseCase;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
use CommerceLinkBuilder\Infrastructure\Persistence\LogListQuery;
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 §12.2 / §12.3 logs endpoints.
 *
 * See the file-level docblock for the synthetic
 * {@see self::META_HTTP_STATUS} envelope key, the permission
 * resolution flow, and the exception mapping order.
 */
final class LogsController {

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

	/**
	 * Stable route token for §12.3 `GET /clb/v1/logs/summary`.
	 * Shares the `'logs.read'` token with the list endpoint by
	 * design — both surfaces are admin-tier diagnostics views and
	 * the §3.2 read-side mapping does not split them.
	 */
	public const ROUTE_TOKEN_SUMMARY = 'logs.read';

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

	/**
	 * §12.2 list use case.
	 */
	private ListLogsUseCase $listUseCase;

	/**
	 * §12.3 summary use case.
	 */
	private GetLogsSummaryUseCase $summaryUseCase;

	/**
	 * §15.2 provider exception → error envelope mapper. Held even
	 * though §12.2 / §12.3 do not raise provider exceptions
	 * themselves, so the constructor matches the canonical
	 * {@see \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}
	 * shape.
	 */
	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 ListLogsUseCase         $listUseCase        §12.2 use case.
	 * @param GetLogsSummaryUseCase   $summaryUseCase     §12.3 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(
		ListLogsUseCase $listUseCase,
		GetLogsSummaryUseCase $summaryUseCase,
		ProviderExceptionMapper $exceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RequestValidator $requestValidator,
		RestRequestAdapter $requestAdapter,
		?callable $capabilityProvider = null
	) {
		$this->listUseCase      = $listUseCase;
		$this->summaryUseCase   = $summaryUseCase;
		$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 );
		};
	}

	/**
	 * §12.2 `GET /clb/v1/logs` dispatch.
	 *
	 * Reads the column-level filters (`level`, `channel`,
	 * `related_product_id`, `related_job_id`) plus the pagination
	 * axes (`page`, `per_page`), validates them, builds a
	 * {@see LogListQuery}, and drives {@see ListLogsUseCase}.
	 * Renders the §12.2 success envelope with HTTP 200.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §12.2 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
			);
		}

		$level            = $this->requestAdapter->paramString( $request, 'level' );
		$channel          = $this->requestAdapter->paramString( $request, 'channel' );
		$relatedProductId = $this->requestAdapter->paramInt( $request, 'related_product_id' );
		$relatedJobId     = $this->requestAdapter->paramInt( $request, 'related_job_id' );
		$page             = $this->requestAdapter->paramInt( $request, 'page', 1 );
		$perPage          = $this->requestAdapter->paramInt(
			$request,
			'per_page',
			LogListQuery::DEFAULT_PER_PAGE
		);

		$result = ValidationResult::valid();

		if ( $level !== null && ! in_array( $level, LogListQuery::VALID_LEVELS, true ) ) {
			$result = $result->withField(
				'level',
				sprintf(
					'Must be one of: %s.',
					implode( ', ', LogListQuery::VALID_LEVELS )
				)
			);
		}

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

		if ( $relatedProductId !== null && $relatedProductId < 1 ) {
			$result = $result->withField( 'related_product_id', 'Must be >= 1.' );
		}

		if ( $relatedJobId !== null && $relatedJobId < 1 ) {
			$result = $result->withField( 'related_job_id', 'Must be >= 1.' );
		}

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

		try {
			$query = new LogListQuery(
				$level,
				$channel,
				$relatedProductId,
				$relatedJobId,
				(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 $this->successResponse( $envelope, 200 );
	}

	/**
	 * §12.3 `GET /clb/v1/logs/summary` dispatch.
	 *
	 * Reads the optional `recent_limit` query parameter (default
	 * 10) and drives {@see GetLogsSummaryUseCase}. Renders the
	 * §12.3 success envelope with HTTP 200.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §12.3 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function summary( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_SUMMARY );
		$ctx      = $this->buildContext( $request, $required );

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

		$recentLimit = $this->requestAdapter->paramInt(
			$request,
			'recent_limit',
			GetLogsSummaryUseCase::DEFAULT_RECENT_LIMIT
		);

		$result = ValidationResult::valid();
		$result = $this->requestValidator->requireInt(
			$result,
			'recent_limit',
			$recentLimit,
			1,
			GetLogsSummaryUseCase::MAX_RECENT_LIMIT
		);

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

		try {
			$summaryResult = $this->summaryUseCase->execute( (int) $recentLimit );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

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

		return $this->successResponse( $envelope, 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 );
	}

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

	/**
	 * 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 ProviderException} → routed through
	 *      {@see ProviderExceptionMapper::map()} for symmetry with
	 *      the canonical
	 *      {@see \CommerceLinkBuilder\Presentation\Rest\Controller\EditorController}
	 *      shape.
	 *   2. {@see InvalidArgumentException} → 422 with
	 *      `details.exception = $e->getMessage()`.
	 *   3. 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; §12.2 / §12.3 themselves are 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 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
		);
	}

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