<?php
/**
 * Audit REST controller.
 *
 * Phase 2 (KI-006) Presentation-layer controller that drives the
 * §12.4 audit-events REST surface documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §12.4 `GET /affilun/v1/audit/events` → {@see self::list()}.
 *
 * The §12.5 `GET /affilun/v1/audit/events/summary` follow-up surface
 * is intentionally out of scope for the KI-006 closeout slice and
 * is tracked as a separate issue.
 *
 * Synthetic envelope key:
 *
 *   - The list method returns
 *     `Envelope::toArray() + array('_http_status' => int)`. The
 *     {@see \Affilun\Presentation\Rest\AuditServiceProvider}
 *     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 \Affilun\Presentation\Rest\Controller\LogsController}
 * pattern):
 *
 *   1. The endpoint uses the §3.2 `'audit.read'` route token,
 *      resolved by {@see CapabilityPolicy::requiredForRead()} to
 *      the OR-list `affilun_view_diagnostics` / `manage_options`.
 *      `affilun_view_diagnostics` is reused here rather than minting a
 *      dedicated `affilun_view_audit` capability so the §12.4 surface
 *      stays inside the existing diagnostics-tier authorization
 *      boundary; a future slice that needs to split the two
 *      audiences can introduce a dedicated capability without
 *      changing this controller.
 *   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 §12.4 success path
 *      runs otherwise.
 *
 * Exception mapping order in {@see self::mapException()}:
 *
 *   1. {@see \InvalidArgumentException} → 422 `affilun_validation_failed`
 *      carrying the exception message in `details.exception`. The
 *      §12.4 query-param parsing path raises this (via
 *      {@see AuditEventListQuery}) when `audit_kind` is
 *      off-whitelist or when the pagination axes are out of range.
 *   2. 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 "no internal detail leakage" posture is preserved.
 *
 * @package Affilun\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Controller;

use Affilun\Application\Audit\UseCase\GetAuditEventsSummaryUseCase;
use Affilun\Application\Audit\UseCase\ListAuditEventsUseCase;
use Affilun\Domain\Provider\Exception\ProviderException;
use Affilun\Infrastructure\Persistence\AuditEventListQuery;
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 §12.4 audit-events endpoint.
 *
 * 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 AuditController {

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

	/**
	 * Stable route token for §12.5 `GET /affilun/v1/audit/events/summary`.
	 * Mapped by {@see CapabilityPolicy} to the same `audit.summary`
	 * read-side OR-list (`affilun_view_diagnostics` /
	 * `manage_options`) the §12.4 list surface uses.
	 */
	public const ROUTE_TOKEN_SUMMARY = 'audit.summary';

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

	/**
	 * §12.4 list use case.
	 */
	private ListAuditEventsUseCase $listUseCase;

	/**
	 * §12.5 summary use case.
	 */
	private ?GetAuditEventsSummaryUseCase $summaryUseCase;

	/**
	 * §15.2 provider exception → error envelope mapper. Held even
	 * though §12.4 does not raise provider exceptions itself, so
	 * the constructor matches the canonical
	 * {@see \Affilun\Presentation\Rest\Controller\LogsController}
	 * 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.
	 *
	 * The §12.5 summary use case is optional so callers that only
	 * exercise the §12.4 list surface (the historical
	 * pre-summary-endpoint test suite) keep working without
	 * modification. When null, {@see self::summary()} returns
	 * a §4.3 `affilun_not_found` envelope so a misconfigured
	 * deployment that registered the summary route without
	 * binding the use case fails closed rather than silently
	 * returning an empty payload.
	 *
	 * @param ListAuditEventsUseCase            $listUseCase        §12.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.
	 * @param GetAuditEventsSummaryUseCase|null $summaryUseCase    Optional §12.5 use case.
	 */
	public function __construct(
		ListAuditEventsUseCase $listUseCase,
		ProviderExceptionMapper $exceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RequestValidator $requestValidator,
		RestRequestAdapter $requestAdapter,
		?callable $capabilityProvider = null,
		?GetAuditEventsSummaryUseCase $summaryUseCase = null
	) {
		$this->listUseCase      = $listUseCase;
		$this->exceptionMapper  = $exceptionMapper;
		$this->capabilityPolicy = $capabilityPolicy;
		$this->requestValidator = $requestValidator;
		$this->requestAdapter   = $requestAdapter;
		$this->summaryUseCase   = $summaryUseCase;

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

	/**
	 * §12.5 `GET /affilun/v1/audit/events/summary` dispatch.
	 *
	 * Reads the optional `recent_limit` query parameter
	 * (defaulting to {@see GetAuditEventsSummaryUseCase::DEFAULT_RECENT_LIMIT}),
	 * validates the capability OR-list and the bound, drives the
	 * §12.5 use case, and renders the success envelope with
	 * HTTP 200.
	 *
	 * Failure modes:
	 *
	 *   - Capability denial → §4.3 `affilun_forbidden` envelope, 403.
	 *   - `recent_limit` outside `[1, MAX_RECENT_LIMIT]` →
	 *     §4.4 validation envelope, 422.
	 *   - Summary use case not bound (misconfigured deployment) →
	 *     §4.3 `affilun_not_found` envelope, 404. Fail-closed posture
	 *     so a half-wired bootstrap surfaces loudly.
	 *   - Other {@see Throwable} → routed through
	 *     {@see self::mapException()} for symmetry with the §12.4
	 *     list path.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §12.5 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
			);
		}

		if ( $this->summaryUseCase === null ) {
			return $this->errorResponse(
				'affilun_endpoint_not_available',
				'Audit summary surface is not available in this deployment.',
				ErrorEnvelope::STATUS_NOT_FOUND,
				$ctx
			);
		}

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

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

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

		try {
			$summaryResult = $this->summaryUseCase->execute( (int) $recentLimit );
		} catch ( InvalidArgumentException $e ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_VALIDATION_FAILED,
				'Validation failed.',
				ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY,
				$ctx,
				array( 'exception' => $e->getMessage() )
			);
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

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

		return $this->successResponse( $envelope, 200 );
	}

	/**
	 * §12.4 `GET /affilun/v1/audit/events` dispatch.
	 *
	 * Reads the column-level filters (`audit_kind`, `actor_id`,
	 * `target_type`, `target_id`) plus the pagination axes
	 * (`page`, `per_page`), validates them, builds an
	 * {@see AuditEventListQuery}, and drives
	 * {@see ListAuditEventsUseCase}. Renders the §12.4 success
	 * envelope with HTTP 200.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §12.4 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
			);
		}

		$auditKind     = $this->requestAdapter->paramString( $request, 'audit_kind' );
		$actorId       = $this->requestAdapter->paramString( $request, 'actor_id' );
		$targetType    = $this->requestAdapter->paramString( $request, 'target_type' );
		$targetId      = $this->requestAdapter->paramString( $request, 'target_id' );
		$requestId     = $this->requestAdapter->paramString( $request, 'request_id' );
		$correlationId = $this->requestAdapter->paramString( $request, 'correlation_id' );
		$jobId         = $this->requestAdapter->paramString( $request, 'job_id' );
		$page          = $this->requestAdapter->paramInt( $request, 'page', 1 );
		$perPage       = $this->requestAdapter->paramInt(
			$request,
			'per_page',
			AuditEventListQuery::DEFAULT_PER_PAGE
		);

		$result = ValidationResult::valid();

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

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

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

		try {
			$query = new AuditEventListQuery(
				$auditKind,
				$actorId,
				$targetType,
				$targetId,
				(int) $page,
				(int) $perPage,
				$requestId,
				$correlationId,
				$jobId
			);
		} 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 );
	}

	/**
	 * 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 \Affilun\Presentation\Rest\Controller\LogsController}
	 *      shape.
	 *   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\LogsController}
	 * shape; §12.4 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 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 );
	}
}
