<?php
/**
 * Immutable value object for paginated product listing queries.
 *
 * Encapsulates the inputs the REST list endpoint described in
 * `specifications/rest-api-spec.md` §5.3 and §8.2 forwards into the
 * persistence layer:
 *
 *   - Pagination axis: `page` (>=1) and `perPage` (1..50, matching the
 *     §5.1 public ceiling).
 *   - Filter axis: `provider`, `search`, `syncState` (the four-value
 *     freshness enum from §5.3 / §6.0), `usedInPostId`, and
 *     `lifecycleState` (the three-value lifecycle enum from §5.3 /
 *     §6.0). All filters are optional; null means "do not constrain
 *     this axis".
 *   - Sort axis: `sortKey` (one of `updated_at` / `created_at` /
 *     `title` / `last_synced_at`, per §8.2) and `sortOrder` (`asc` /
 *     `desc`, per §5.2). Defaults are `updated_at` / `desc`, matching
 *     the spec's "newest activity first" admin-list convention.
 *
 * Design notes:
 *
 *   - Closed-set tokens (`syncState`, `lifecycleState`, `sortKey`,
 *     `sortOrder`) are exposed as public class constants and as
 *     static `validXxx()` lists so call sites — REST mappers, admin
 *     UI dropdowns, persistence implementations — can enumerate the
 *     accepted vocabulary without duplicating the literals.
 *
 *   - Validation is exhaustive at construction time so persistence
 *     implementations may interpolate `sortKey` and `sortOrder`
 *     directly into SQL without re-validating: by the time a query
 *     reaches the repository, every closed-set token is guaranteed
 *     to be on the whitelist. The wither methods replay the same
 *     validation through the constructor so an off-whitelist token
 *     cannot sneak in via a partial mutation.
 *
 *   - `provider` and `search` are nullable strings; when supplied,
 *     they MUST be non-empty after trimming. Empty-after-trim is
 *     rejected loudly because the REST layer is responsible for
 *     coercing whitespace-only inputs to "filter absent" before
 *     reaching this VO — letting them through would silently match
 *     every row.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Immutable list-query inputs for {@see ProductRepository::findPaginated()}.
 *
 * See the file-level docblock for the spec-driven shape of every axis
 * and the rationale for exhaustive constructor-time validation.
 */
final class ProductListQuery {

	/**
	 * Sort by `updated_at` (default).
	 */
	public const SORT_KEY_UPDATED_AT = 'updated_at';

	/**
	 * Sort by `created_at`.
	 */
	public const SORT_KEY_CREATED_AT = 'created_at';

	/**
	 * Sort by `title`.
	 */
	public const SORT_KEY_TITLE = 'title';

	/**
	 * Sort by `last_synced_at`.
	 */
	public const SORT_KEY_LAST_SYNCED_AT = 'last_synced_at';

	/**
	 * Ascending sort order.
	 */
	public const SORT_ORDER_ASC = 'asc';

	/**
	 * Descending sort order (default).
	 */
	public const SORT_ORDER_DESC = 'desc';

	/**
	 * Sync-state freshness filter: `fresh`.
	 */
	public const SYNC_STATE_FRESH = 'fresh';

	/**
	 * Sync-state freshness filter: `stale`.
	 */
	public const SYNC_STATE_STALE = 'stale';

	/**
	 * Sync-state freshness filter: `expired`.
	 */
	public const SYNC_STATE_EXPIRED = 'expired';

	/**
	 * Sync-state freshness filter: `broken`.
	 */
	public const SYNC_STATE_BROKEN = 'broken';

	/**
	 * Lifecycle filter: `active`.
	 */
	public const LIFECYCLE_STATE_ACTIVE = 'active';

	/**
	 * Lifecycle filter: `archived`.
	 */
	public const LIFECYCLE_STATE_ARCHIVED = 'archived';

	/**
	 * Lifecycle filter: `deleted_pending`.
	 */
	public const LIFECYCLE_STATE_DELETED_PENDING = 'deleted_pending';

	/**
	 * Default per-page count (per §5.1).
	 */
	private const DEFAULT_PER_PAGE = 20;

	/**
	 * Maximum per-page count (per §5.1).
	 */
	private const MAX_PER_PAGE = 50;

	/**
	 * 1-based page number.
	 */
	private int $page;

	/**
	 * Number of records per page (1..50).
	 */
	private int $perPage;

	/**
	 * Provider exact-match filter, or null when absent.
	 */
	private ?string $provider;

	/**
	 * Title substring filter, or null when absent.
	 */
	private ?string $search;

	/**
	 * Sync-state exact-match filter, or null when absent.
	 */
	private ?string $syncState;

	/**
	 * `affilun_embeds.post_id` filter, or null when absent.
	 *
	 * Currently accepted but not honored by either repository
	 * implementation; honored in Phase 9 once the sync / usage
	 * table lands.
	 */
	private ?int $usedInPostId;

	/**
	 * Lifecycle-state exact-match filter, or null when absent.
	 */
	private ?string $lifecycleState;

	/**
	 * Closed-set sort key.
	 */
	private string $sortKey;

	/**
	 * Closed-set sort order.
	 */
	private string $sortOrder;

	/**
	 * Construct the query with full validation.
	 *
	 * @param int         $page           1-based page number (>=1).
	 * @param int         $perPage        Records per page (1..50).
	 * @param string|null $provider       Provider exact-match filter, or null.
	 * @param string|null $search         Title substring filter, or null.
	 * @param string|null $syncState      Sync-state filter (closed set), or null.
	 * @param int|null    $usedInPostId   `affilun_embeds.post_id` filter (>=1), or null.
	 * @param string|null $lifecycleState Lifecycle filter (closed set), or null.
	 * @param string      $sortKey        Sort key (closed set).
	 * @param string      $sortOrder      Sort order (closed set).
	 *
	 * @throws InvalidArgumentException When any axis is out of range
	 *                                  or off-whitelist.
	 */
	public function __construct(
		int $page = 1,
		int $perPage = self::DEFAULT_PER_PAGE,
		?string $provider = null,
		?string $search = null,
		?string $syncState = null,
		?int $usedInPostId = null,
		?string $lifecycleState = null,
		string $sortKey = self::SORT_KEY_UPDATED_AT,
		string $sortOrder = self::SORT_ORDER_DESC
	) {
		if ( $page < 1 ) {
			$message = sprintf( 'ProductListQuery: page must be >= 1, got %d.', $page );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $perPage < 1 || $perPage > self::MAX_PER_PAGE ) {
			$message = sprintf(
				'ProductListQuery: perPage must be between 1 and %d, got %d.',
				self::MAX_PER_PAGE,
				$perPage
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $provider !== null && trim( $provider ) === '' ) {
			$message = 'ProductListQuery: provider must be null or a non-empty (post-trim) string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $search !== null && trim( $search ) === '' ) {
			$message = 'ProductListQuery: search must be null or a non-empty (post-trim) string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $syncState !== null && ! in_array( $syncState, self::validSyncStates(), true ) ) {
			$message = sprintf(
				'ProductListQuery: syncState "%s" is not in the closed set [%s].',
				$syncState,
				implode( ', ', self::validSyncStates() )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $usedInPostId !== null && $usedInPostId < 1 ) {
			$message = sprintf(
				'ProductListQuery: usedInPostId must be null or >= 1, got %d.',
				$usedInPostId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $lifecycleState !== null && ! in_array( $lifecycleState, self::validLifecycleStates(), true ) ) {
			$message = sprintf(
				'ProductListQuery: lifecycleState "%s" is not in the closed set [%s].',
				$lifecycleState,
				implode( ', ', self::validLifecycleStates() )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! in_array( $sortKey, self::validSortKeys(), true ) ) {
			$message = sprintf(
				'ProductListQuery: sortKey "%s" is not in the closed set [%s].',
				$sortKey,
				implode( ', ', self::validSortKeys() )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! in_array( $sortOrder, self::validSortOrders(), true ) ) {
			$message = sprintf(
				'ProductListQuery: sortOrder "%s" is not in the closed set [%s].',
				$sortOrder,
				implode( ', ', self::validSortOrders() )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->page           = $page;
		$this->perPage        = $perPage;
		$this->provider       = $provider;
		$this->search         = $search;
		$this->syncState      = $syncState;
		$this->usedInPostId   = $usedInPostId;
		$this->lifecycleState = $lifecycleState;
		$this->sortKey        = $sortKey;
		$this->sortOrder      = $sortOrder;
	}

	/**
	 * Default query: page 1, 20 per page, no filters, sort by
	 * `updated_at` desc — the spec's "newest activity first" default.
	 */
	public static function defaults(): self {
		return new self();
	}

	/**
	 * Closed-set list of accepted sort keys (per §8.2).
	 *
	 * @return list<string>
	 */
	public static function validSortKeys(): array {
		return array(
			self::SORT_KEY_UPDATED_AT,
			self::SORT_KEY_CREATED_AT,
			self::SORT_KEY_TITLE,
			self::SORT_KEY_LAST_SYNCED_AT,
		);
	}

	/**
	 * Closed-set list of accepted sort orders (per §5.2).
	 *
	 * @return list<string>
	 */
	public static function validSortOrders(): array {
		return array(
			self::SORT_ORDER_ASC,
			self::SORT_ORDER_DESC,
		);
	}

	/**
	 * Closed-set list of accepted sync-state filters (per §5.3 / §6.0).
	 *
	 * @return list<string>
	 */
	public static function validSyncStates(): array {
		return array(
			self::SYNC_STATE_FRESH,
			self::SYNC_STATE_STALE,
			self::SYNC_STATE_EXPIRED,
			self::SYNC_STATE_BROKEN,
		);
	}

	/**
	 * Closed-set list of accepted lifecycle-state filters (per §5.3 / §6.0).
	 *
	 * @return list<string>
	 */
	public static function validLifecycleStates(): array {
		return array(
			self::LIFECYCLE_STATE_ACTIVE,
			self::LIFECYCLE_STATE_ARCHIVED,
			self::LIFECYCLE_STATE_DELETED_PENDING,
		);
	}

	/**
	 * Return a new query with the given page number.
	 *
	 * @param int $page 1-based page number (>=1).
	 *
	 * @throws InvalidArgumentException When `$page < 1`.
	 */
	public function withPage( int $page ): self {
		return new self(
			$page,
			$this->perPage,
			$this->provider,
			$this->search,
			$this->syncState,
			$this->usedInPostId,
			$this->lifecycleState,
			$this->sortKey,
			$this->sortOrder
		);
	}

	/**
	 * Return a new query with the given per-page count.
	 *
	 * @param int $perPage Records per page (1..50).
	 *
	 * @throws InvalidArgumentException When `$perPage` is out of range.
	 */
	public function withPerPage( int $perPage ): self {
		return new self(
			$this->page,
			$perPage,
			$this->provider,
			$this->search,
			$this->syncState,
			$this->usedInPostId,
			$this->lifecycleState,
			$this->sortKey,
			$this->sortOrder
		);
	}

	/**
	 * Return a new query with the given provider filter (or null).
	 *
	 * @param string|null $provider Provider exact-match filter, or null.
	 *
	 * @throws InvalidArgumentException When `$provider` is empty after trim.
	 */
	public function withProvider( ?string $provider ): self {
		return new self(
			$this->page,
			$this->perPage,
			$provider,
			$this->search,
			$this->syncState,
			$this->usedInPostId,
			$this->lifecycleState,
			$this->sortKey,
			$this->sortOrder
		);
	}

	/**
	 * Return a new query with the given search filter (or null).
	 *
	 * @param string|null $search Title substring filter, or null.
	 *
	 * @throws InvalidArgumentException When `$search` is empty after trim.
	 */
	public function withSearch( ?string $search ): self {
		return new self(
			$this->page,
			$this->perPage,
			$this->provider,
			$search,
			$this->syncState,
			$this->usedInPostId,
			$this->lifecycleState,
			$this->sortKey,
			$this->sortOrder
		);
	}

	/**
	 * Return a new query with the given sync-state filter (or null).
	 *
	 * @param string|null $syncState Sync-state filter (closed set), or null.
	 *
	 * @throws InvalidArgumentException When `$syncState` is off-whitelist.
	 */
	public function withSyncState( ?string $syncState ): self {
		return new self(
			$this->page,
			$this->perPage,
			$this->provider,
			$this->search,
			$syncState,
			$this->usedInPostId,
			$this->lifecycleState,
			$this->sortKey,
			$this->sortOrder
		);
	}

	/**
	 * Return a new query with the given used-in-post-id filter (or null).
	 *
	 * @param int|null $usedInPostId `affilun_embeds.post_id` filter (>=1), or null.
	 *
	 * @throws InvalidArgumentException When `$usedInPostId < 1`.
	 */
	public function withUsedInPostId( ?int $usedInPostId ): self {
		return new self(
			$this->page,
			$this->perPage,
			$this->provider,
			$this->search,
			$this->syncState,
			$usedInPostId,
			$this->lifecycleState,
			$this->sortKey,
			$this->sortOrder
		);
	}

	/**
	 * Return a new query with the given lifecycle-state filter (or null).
	 *
	 * @param string|null $lifecycleState Lifecycle filter (closed set), or null.
	 *
	 * @throws InvalidArgumentException When `$lifecycleState` is off-whitelist.
	 */
	public function withLifecycleState( ?string $lifecycleState ): self {
		return new self(
			$this->page,
			$this->perPage,
			$this->provider,
			$this->search,
			$this->syncState,
			$this->usedInPostId,
			$lifecycleState,
			$this->sortKey,
			$this->sortOrder
		);
	}

	/**
	 * Return a new query with the given sort axis.
	 *
	 * @param string $key   Sort key (closed set).
	 * @param string $order Sort order (closed set).
	 *
	 * @throws InvalidArgumentException When either argument is off-whitelist.
	 */
	public function withSort( string $key, string $order ): self {
		return new self(
			$this->page,
			$this->perPage,
			$this->provider,
			$this->search,
			$this->syncState,
			$this->usedInPostId,
			$this->lifecycleState,
			$key,
			$order
		);
	}

	/**
	 * 1-based page number.
	 */
	public function page(): int {
		return $this->page;
	}

	/**
	 * Number of records per page.
	 */
	public function perPage(): int {
		return $this->perPage;
	}

	/**
	 * Provider exact-match filter, or null when absent.
	 */
	public function provider(): ?string {
		return $this->provider;
	}

	/**
	 * Title substring filter, or null when absent.
	 */
	public function search(): ?string {
		return $this->search;
	}

	/**
	 * Sync-state exact-match filter, or null when absent.
	 */
	public function syncState(): ?string {
		return $this->syncState;
	}

	/**
	 * `affilun_embeds.post_id` filter, or null when absent.
	 */
	public function usedInPostId(): ?int {
		return $this->usedInPostId;
	}

	/**
	 * Lifecycle-state exact-match filter, or null when absent.
	 */
	public function lifecycleState(): ?string {
		return $this->lifecycleState;
	}

	/**
	 * Closed-set sort key.
	 */
	public function sortKey(): string {
		return $this->sortKey;
	}

	/**
	 * Closed-set sort order.
	 */
	public function sortOrder(): string {
		return $this->sortOrder;
	}

	/**
	 * Deep equality against another query.
	 *
	 * @param self $other Query to compare.
	 */
	public function equals( self $other ): bool {
		return $this->page === $other->page
			&& $this->perPage === $other->perPage
			&& $this->provider === $other->provider
			&& $this->search === $other->search
			&& $this->syncState === $other->syncState
			&& $this->usedInPostId === $other->usedInPostId
			&& $this->lifecycleState === $other->lifecycleState
			&& $this->sortKey === $other->sortKey
			&& $this->sortOrder === $other->sortOrder;
	}
}
