<?php
/**
 * Logs 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 LogListQuery} value object suitable for forwarding
 * to {@see \Affilun\Application\Logs\UseCase\ListLogsUseCase}.
 *
 * The parser is permissive by design: any malformed / off-whitelist
 * input that would otherwise cause
 * {@see LogListQuery::__construct()} to throw an
 * {@see InvalidArgumentException} is silently dropped (filter axis
 * coerced to null, `perPage` clamped to {@see LogListQuery::MAX_PER_PAGE},
 * unrecoverable construction errors fall back to
 * {@see LogListQuery::defaults()}). The motivation is the §6 Logs admin
 * page surviving bookmarked URLs across spec changes — an admin
 * landing on `?level=oldname` after a vocabulary tweak should still see
 * a usable page rather than a fatal.
 *
 * Per-axis behavior:
 *
 *   - `level` — closed-set PSR-3 string; off-whitelist values dropped.
 *   - `channel` — non-empty string; whitespace-only coerced to null.
 *   - `related_product_id` — positive integer; non-positive dropped.
 *   - `related_job_id` — positive integer; non-positive dropped.
 *   - `paged` — integer >= 1; default 1.
 *   - `per_page` — integer 1..MAX_PER_PAGE; clamped to the max,
 *      out-of-range below 1 falls back to the default.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Infrastructure\Persistence\LogListQuery;
use InvalidArgumentException;

/**
 * Pure-function helper that converts the `$_GET` array of the §6 Logs
 * admin page into a normalized {@see LogListQuery}.
 *
 * 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 LogsListQueryParser {

	/**
	 * `$_GET` key carrying the PSR-3 level filter.
	 */
	public const KEY_LEVEL = 'level';

	/**
	 * `$_GET` key carrying the channel exact-match filter.
	 */
	public const KEY_CHANNEL = 'channel';

	/**
	 * `$_GET` key carrying the related-product-id filter.
	 */
	public const KEY_RELATED_PRODUCT_ID = 'related_product_id';

	/**
	 * `$_GET` key carrying the related-job-id filter.
	 */
	public const KEY_RELATED_JOB_ID = 'related_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 a {@see LogListQuery}.
	 *
	 * Construction failures fall back to {@see LogListQuery::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 ): LogListQuery {
		$level = self::parseNonEmptyString( $rawGet, self::KEY_LEVEL );
		if ( $level !== null && ! in_array( $level, LogListQuery::VALID_LEVELS, true ) ) {
			$level = null;
		}

		$channel          = self::parseNonEmptyString( $rawGet, self::KEY_CHANNEL );
		$relatedProductId = self::parsePositiveIntOrNull( $rawGet, self::KEY_RELATED_PRODUCT_ID );
		$relatedJobId     = self::parsePositiveIntOrNull( $rawGet, self::KEY_RELATED_JOB_ID );

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

		try {
			return new LogListQuery(
				$level,
				$channel,
				$relatedProductId,
				$relatedJobId,
				$page,
				$perPage
			);
		} catch ( InvalidArgumentException $e ) {
			unset( $e );
			return LogListQuery::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 an optional positive-integer axis from `$rawGet`, returning
	 * null 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.
	 */
	private static function parsePositiveIntOrNull( array $rawGet, string $key ): ?int {
		if ( ! array_key_exists( $key, $rawGet ) ) {
			return null;
		}

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

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

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

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