<?php
/**
 * Sync Jobs list-page query parser.
 *
 * Phase 11 slice 11f helper that converts a `$_GET`-shaped associative
 * array (already unslashed by the caller) into a normalized
 * {@see SyncJobListQuery} value object suitable for forwarding to
 * {@see \CommerceLinkBuilder\Application\SyncJobs\UseCase\ListSyncJobsUseCase}.
 *
 * Wire ⇔ DB status translation: the admin page filter form speaks
 * wire vocabulary (`queued|running|completed|failed|cancelled`) per
 * `specifications/rest-api-spec.md` §6.0; the persistence-layer
 * {@see SyncJobListQuery} speaks DB vocabulary
 * (`pending|running|completed|failed|cancelled`). This parser is the
 * seam where wire `queued` is rewritten to DB `pending` before
 * constructing the query VO. The inverse mapping (DB `pending` → wire
 * `queued` on the row's `status` field) lives on
 * {@see \CommerceLinkBuilder\Application\SyncJobs\UseCase\ListSyncJobsUseCase}.
 *
 * The parser is permissive by design: any malformed / off-whitelist
 * input that would otherwise cause {@see SyncJobListQuery::__construct()}
 * to throw is silently dropped (filter axis coerced to null, `perPage`
 * clamped, unrecoverable construction errors fall back to
 * {@see SyncJobListQuery::defaults()}). The motivation is the §6 Sync
 * Jobs admin page surviving bookmarked URLs across spec changes.
 *
 * Per-axis behavior:
 *
 *   - `status` — wire-vocabulary string. Wire `queued` is rewritten to
 *      DB `pending`; every other status passes through. Off-whitelist
 *      values are silently dropped to null.
 *   - `job_type` — closed-set string from
 *      {@see SyncJobListQuery::VALID_JOB_TYPES}; off-whitelist values
 *      dropped.
 *   - `paged` — integer >= 1; default 1.
 *   - `per_page` — integer 1..MAX_PER_PAGE; clamped to the max.
 *
 * @package CommerceLinkBuilder\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin\Page;

use CommerceLinkBuilder\Infrastructure\Persistence\SyncJobListQuery;
use InvalidArgumentException;

/**
 * Pure-function helper that converts the `$_GET` array of the §6 Sync
 * Jobs admin page into a normalized {@see SyncJobListQuery}.
 *
 * See the file-level docblock for the per-axis parsing rules, the
 * wire ⇔ DB status translation, and the graceful-fallback contract.
 */
final class SyncJobsListQueryParser {

	/**
	 * `$_GET` key carrying the wire-vocabulary status filter.
	 */
	public const KEY_STATUS = 'status';

	/**
	 * `$_GET` key carrying the job-type filter.
	 */
	public const KEY_JOB_TYPE = 'job_type';

	/**
	 * `$_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';

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

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

	/**
	 * Convert `$_GET`-shaped input into a {@see SyncJobListQuery}.
	 *
	 * Construction failures fall back to
	 * {@see SyncJobListQuery::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 ): SyncJobListQuery {
		$status = self::parseNonEmptyString( $rawGet, self::KEY_STATUS );
		if ( $status !== null ) {
			if ( $status === self::WIRE_STATUS_QUEUED ) {
				$status = self::DB_STATUS_PENDING;
			}
			if ( ! in_array( $status, SyncJobListQuery::VALID_STATUSES, true ) ) {
				$status = null;
			}
		}

		$jobType = self::parseNonEmptyString( $rawGet, self::KEY_JOB_TYPE );
		if ( $jobType !== null && ! in_array( $jobType, SyncJobListQuery::VALID_JOB_TYPES, true ) ) {
			$jobType = null;
		}

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

		try {
			return new SyncJobListQuery( $status, $jobType, $page, $perPage );
		} catch ( InvalidArgumentException $e ) {
			unset( $e );
			return SyncJobListQuery::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.
	 *
	 * @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;
	}
}
