<?php
/**
 * Audit-events list-page query parser.
 *
 * Phase 2 (KI-006 follow-up) helper that converts a `$_GET`-shaped
 * associative array (already unslashed by the caller) into a
 * normalized {@see AuditEventListQuery} value object suitable for
 * forwarding to {@see \Affilun\Application\Audit\UseCase\ListAuditEventsUseCase}
 * from the §10 Audit Events admin page.
 *
 * The parser is permissive by design: any malformed / off-whitelist
 * input that would otherwise cause
 * {@see AuditEventListQuery::__construct()} to throw an
 * {@see InvalidArgumentException} is silently dropped (filter axis
 * coerced to null, `per_page` clamped to
 * {@see AuditEventListQuery::MAX_PER_PAGE}, unrecoverable
 * construction errors fall back to
 * {@see AuditEventListQuery::defaults()}). The motivation matches
 * the §6 Logs admin page parser: the audit admin page must survive
 * bookmarked URLs across spec changes — an admin landing on
 * `?audit_kind=oldname` after a vocabulary tweak should still see
 * a usable page rather than a fatal.
 *
 * Per-axis behavior:
 *
 *   - `audit_kind` — closed-set §10.2 audit-kind string;
 *     off-whitelist values dropped (no-filter posture preserved).
 *   - `actor_id` — non-empty string; whitespace-only coerced to null.
 *   - `target_type` — non-empty string; whitespace-only coerced to null.
 *   - `target_id` — non-empty string; whitespace-only coerced to null.
 *   - `request_id` — non-empty string carried via
 *     `context_json.$.request_id` (Phase 2 KI-006 follow-up).
 *   - `correlation_id` — non-empty string carried via
 *     `context_json.$.correlation_id` (Phase 2 KI-006 follow-up).
 *   - `job_id` — non-empty string carried via
 *     `context_json.$.job_id` (Phase 2 KI-006 follow-up).
 *   - `paged` — integer >= 1; default 1. Mirrors WordPress's own
 *     `paged` query var so the admin pagination links interoperate
 *     with core conventions.
 *   - `per_page` — integer 1..{@see AuditEventListQuery::MAX_PER_PAGE};
 *     clamped to the max, out-of-range below 1 falls back to the
 *     default.
 *
 * Mirrors {@see \Affilun\Presentation\Admin\Page\LogsListQueryParser}
 * verbatim except for the per-axis vocabulary so the §6 Logs admin
 * page and the (Phase 2 follow-up) §10 Audit Events admin page
 * share the same parsing conventions and graceful-fallback
 * contract.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Infrastructure\Persistence\AuditEventListQuery;
use InvalidArgumentException;

/**
 * Pure-function helper that converts the `$_GET` array of the §10
 * Audit Events admin page into a normalized
 * {@see AuditEventListQuery}.
 *
 * See the file-level docblock for the per-axis parsing rules and
 * the graceful-fallback contract that catches construction errors
 * from a tampered or stale URL.
 */
final class AuditListQueryParser {

	/**
	 * `$_GET` key carrying the §10.2 audit-kind filter.
	 */
	public const KEY_AUDIT_KIND = 'audit_kind';

	/**
	 * `$_GET` key carrying the §10.2 actor-id exact-match filter.
	 */
	public const KEY_ACTOR_ID = 'actor_id';

	/**
	 * `$_GET` key carrying the §10.2 target-type exact-match filter.
	 */
	public const KEY_TARGET_TYPE = 'target_type';

	/**
	 * `$_GET` key carrying the §10.2 target-id exact-match filter.
	 */
	public const KEY_TARGET_ID = 'target_id';

	/**
	 * `$_GET` key carrying the `context_json.$.request_id`
	 * exact-match filter (Phase 2 KI-006 follow-up).
	 */
	public const KEY_REQUEST_ID = 'request_id';

	/**
	 * `$_GET` key carrying the `context_json.$.correlation_id`
	 * exact-match filter (Phase 2 KI-006 follow-up).
	 */
	public const KEY_CORRELATION_ID = 'correlation_id';

	/**
	 * `$_GET` key carrying the `context_json.$.job_id` exact-match
	 * filter (Phase 2 KI-006 follow-up).
	 */
	public const KEY_JOB_ID = 'job_id';

	/**
	 * `$_GET` key carrying the 1-based page number.
	 *
	 * Mirrors WordPress's own `paged` query var so the admin
	 * pagination links interoperate with core conventions.
	 */
	public const KEY_PAGE = 'paged';

	/**
	 * `$_GET` key carrying the records-per-page count.
	 */
	public const KEY_PER_PAGE = 'per_page';

	/**
	 * Convert `$_GET`-shaped input into an
	 * {@see AuditEventListQuery}.
	 *
	 * Construction failures fall back to
	 * {@see AuditEventListQuery::defaults()} so a malformed URL
	 * cannot break the admin page rendering.
	 *
	 * @param array<string, mixed> $rawGet `$_GET`-shaped input (already unslashed).
	 */
	public function parse( array $rawGet ): AuditEventListQuery {
		$auditKind = self::parseNonEmptyString( $rawGet, self::KEY_AUDIT_KIND );
		if ( $auditKind !== null && ! in_array( $auditKind, AuditEventListQuery::VALID_AUDIT_KINDS, true ) ) {
			$auditKind = null;
		}

		$actorId       = self::parseNonEmptyString( $rawGet, self::KEY_ACTOR_ID );
		$targetType    = self::parseNonEmptyString( $rawGet, self::KEY_TARGET_TYPE );
		$targetId      = self::parseNonEmptyString( $rawGet, self::KEY_TARGET_ID );
		$requestId     = self::parseNonEmptyString( $rawGet, self::KEY_REQUEST_ID );
		$correlationId = self::parseNonEmptyString( $rawGet, self::KEY_CORRELATION_ID );
		$jobId         = self::parseNonEmptyString( $rawGet, self::KEY_JOB_ID );

		$page    = self::parsePositiveInt( $rawGet, self::KEY_PAGE, 1 );
		$perPage = self::parsePositiveInt( $rawGet, self::KEY_PER_PAGE, AuditEventListQuery::DEFAULT_PER_PAGE );
		if ( $perPage > AuditEventListQuery::MAX_PER_PAGE ) {
			$perPage = AuditEventListQuery::MAX_PER_PAGE;
		}

		try {
			return new AuditEventListQuery(
				$auditKind,
				$actorId,
				$targetType,
				$targetId,
				$page,
				$perPage,
				$requestId,
				$correlationId,
				$jobId
			);
		} catch ( InvalidArgumentException $e ) {
			unset( $e );
			return AuditEventListQuery::defaults();
		}
	}

	/**
	 * Read a positive-integer axis from `$rawGet`, falling back to
	 * `$fallback` when the key is missing, non-scalar, non-numeric,
	 * or below 1.
	 *
	 * @param array<string, mixed> $rawGet   $_GET-shaped input.
	 * @param string               $key      Axis key.
	 * @param int                  $fallback Default value when absent.
	 */
	private static function parsePositiveInt( array $rawGet, string $key, int $fallback ): int {
		if ( ! array_key_exists( $key, $rawGet ) ) {
			return $fallback;
		}

		$raw = $rawGet[ $key ];
		if ( ! is_scalar( $raw ) ) {
			return $fallback;
		}

		$asString = trim( (string) $raw );
		if ( $asString === '' || preg_match( '/^-?\d+$/', $asString ) !== 1 ) {
			return $fallback;
		}

		$value = (int) $asString;
		if ( $value < 1 ) {
			return $fallback;
		}

		return $value;
	}

	/**
	 * Read a non-empty (post-trim) string axis from `$rawGet`,
	 * returning null when the key is missing, non-scalar, or
	 * whitespace-only.
	 *
	 * Routes through `sanitize_text_field()` when available so a
	 * malicious URL parameter cannot smuggle markup into the
	 * rendered filter form. The resulting string is also handed to
	 * {@see AuditEventListQuery::__construct()}, which performs a
	 * second `trim()`-and-null-if-empty pass via
	 * {@see AuditEventListQuery normalizeOptional()}; the parser-side
	 * pass is kept so the off-whitelist branch on `audit_kind`
	 * compares against an already-trimmed value.
	 *
	 * @param array<string, mixed> $rawGet $_GET-shaped input.
	 * @param string               $key    Axis key.
	 */
	private static function parseNonEmptyString( array $rawGet, string $key ): ?string {
		if ( ! array_key_exists( $key, $rawGet ) ) {
			return null;
		}

		$raw = $rawGet[ $key ];
		if ( ! is_scalar( $raw ) ) {
			return null;
		}

		$asString = (string) $raw;
		if ( function_exists( 'sanitize_text_field' ) ) {
			$asString = (string) sanitize_text_field( $asString );
		}

		$trimmed = trim( $asString );
		if ( $trimmed === '' ) {
			return null;
		}

		return $trimmed;
	}
}
