<?php
/**
 * Products list-page query parser.
 *
 * Phase-8 slice 8d helper that converts a `$_GET`-shaped
 * associative array (already unslashed by the caller) into a
 * normalized {@see ProductListQuery} value object suitable for
 * forwarding to {@see ListProductsUseCase}.
 *
 * The parser is permissive by design: any malformed / off-whitelist
 * input that would otherwise cause
 * {@see ProductListQuery::__construct()} to throw an
 * {@see InvalidArgumentException} is caught and the parser falls
 * back to {@see ProductListQuery::defaults()} so a tampered URL
 * (e.g. `?affilun_sort=nope`) cannot break the admin page rendering.
 *
 * Per-axis behaviour:
 *
 *   - `affilun_page`      — integer >= 1; non-positive values fall back
 *                       to the defaults envelope.
 *   - `affilun_per_page`  — integer 1..50; out-of-range values fall
 *                       back to defaults.
 *   - `affilun_provider`  — non-empty string; whitespace-only / missing
 *                       coerced to null.
 *   - `affilun_sync_state`— closed-set string; invalid values fall back
 *                       to null (the `ProductListQuery` constructor
 *                       enforces the whitelist).
 *   - `affilun_search`    — non-empty (post-trim) string; whitespace-only
 *                       coerced to null.
 *   - `affilun_sort`      — closed-set string; default `'updated_at'`.
 *   - `affilun_order`     — closed-set string; default `'desc'`.
 *
 * The parser routes every string axis through `sanitize_text_field()`
 * when available so a malicious URL parameter cannot smuggle an
 * executable payload into the rendered filter form via a `selected`
 * attribute. The function-exists guard keeps the parser usable in
 * pure-unit harnesses where WordPress is not loaded.
 *
 * See `specifications/admin-ui-spec.md` §6.4 (filters) and
 * `specifications/rest-api-spec.md` §5.1 / §5.3 (pagination,
 * filters).
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Infrastructure\Persistence\ProductListQuery;
use InvalidArgumentException;

/**
 * Pure-function helper that converts the `$_GET` array of the §6
 * Products list page into a normalized {@see ProductListQuery}.
 *
 * See the file-level docblock for the per-axis parsing rules and
 * the graceful-fallback contract that catches construction errors
 * from a tampered URL.
 */
final class ProductsListQueryParser {

	/**
	 * `$_GET` key carrying the 1-based page number.
	 */
	public const KEY_PAGE = 'affilun_page';

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

	/**
	 * `$_GET` key carrying the provider exact-match filter.
	 */
	public const KEY_PROVIDER = 'affilun_provider';

	/**
	 * `$_GET` key carrying the sync-state filter.
	 */
	public const KEY_SYNC_STATE = 'affilun_sync_state';

	/**
	 * `$_GET` key carrying the title substring filter.
	 */
	public const KEY_SEARCH = 'affilun_search';

	/**
	 * `$_GET` key carrying the user-side state filter.
	 */
	public const KEY_USER_STATE = 'affilun_user_state';

	/**
	 * Legacy `$_GET` key that used to toggle deleted products into the list.
	 */
	public const KEY_SHOW_DELETED = 'affilun_show_deleted';

	/**
	 * `$_GET` key carrying the sort-axis literal.
	 */
	public const KEY_SORT = 'affilun_sort';

	/**
	 * `$_GET` key carrying the sort-order literal.
	 */
	public const KEY_ORDER = 'affilun_order';

	/**
	 * Default records-per-page when `$_GET` omits the axis.
	 */
	private const DEFAULT_PER_PAGE = 20;

	/**
	 * Convert `$_GET`-shaped input into a {@see ProductListQuery}.
	 *
	 * Construction failures (e.g. a tampered `affilun_sort` token that
	 * survives every per-axis trim but trips the closed-set check
	 * inside {@see ProductListQuery::__construct()}) are caught and
	 * mapped to {@see ProductListQuery::defaults()} so a malformed
	 * URL cannot break the admin page rendering.
	 *
	 * @param array<string, mixed> $getData `$_GET`-shaped input
	 *                                      (already unslashed).
	 */
	public function parse( array $getData ): ProductListQuery {
		$page    = self::parsePositiveInt( $getData, self::KEY_PAGE, 1 );
		$perPage = self::parsePositiveInt( $getData, self::KEY_PER_PAGE, self::DEFAULT_PER_PAGE );

		$provider  = self::parseNonEmptyString( $getData, self::KEY_PROVIDER );
		$syncState = self::parseNonEmptyString( $getData, self::KEY_SYNC_STATE );
		$search    = self::parseNonEmptyString( $getData, self::KEY_SEARCH );
		$userState = self::parseNonEmptyString( $getData, self::KEY_USER_STATE );
		$lifecycle = self::lifecycleFromUserState( $userState );

		$sortKey   = self::parseNonEmptyString( $getData, self::KEY_SORT )
			?? ProductListQuery::SORT_KEY_UPDATED_AT;
		$sortOrder = self::parseNonEmptyString( $getData, self::KEY_ORDER )
			?? ProductListQuery::SORT_ORDER_DESC;

		try {
			return new ProductListQuery(
				$page,
				$perPage,
				$provider,
				$search,
				$syncState,
				null,
				$lifecycle,
				$sortKey,
				$sortOrder
			);
		} catch ( InvalidArgumentException $e ) {
			unset( $e );
			return ProductListQuery::defaults();
		}
	}

	/**
	 * Read a positive-integer axis from `$getData`, falling back to
	 * `$default` when the key is missing, non-scalar, non-numeric,
	 * or below 1.
	 *
	 * The parser does NOT clamp the value to the
	 * {@see ProductListQuery} per-page maximum; out-of-range values
	 * are forwarded so the constructor's exhaustive validation
	 * triggers the parser's defaults() fallback in {@see self::parse()}.
	 *
	 * @param array<string, mixed> $getData  $_GET-shaped input.
	 * @param string               $key      Axis key.
	 * @param int                  $fallback Default value when absent.
	 */
	private static function parsePositiveInt( array $getData, string $key, int $fallback ): int {
		if ( ! array_key_exists( $key, $getData ) ) {
			return $fallback;
		}

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

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

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

		return $value;
	}

	/**
	 * Convert the user-side state token into the stored lifecycle state.
	 *
	 * @param string|null $userState User-side state token.
	 */
	private static function lifecycleFromUserState( ?string $userState ): ?string {
		return match ( $userState ) {
			'publish' => ProductListQuery::LIFECYCLE_STATE_ACTIVE,
			'unused' => ProductListQuery::LIFECYCLE_STATE_ARCHIVED,
			'trash' => ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING,
			default => null,
		};
	}

	/**
	 * Read a non-empty (post-trim) string axis from `$getData`,
	 * 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's `selected` / `value` attributes.
	 *
	 * @param array<string, mixed> $getData $_GET-shaped input.
	 * @param string               $key     Axis key.
	 */
	private static function parseNonEmptyString( array $getData, string $key ): ?string {
		if ( ! array_key_exists( $key, $getData ) ) {
			return null;
		}

		$raw = $getData[ $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;
	}
}
