<?php
/**
 * Immutable value object for paginated sync-job listing queries.
 *
 * Encapsulates the column-level inputs the sync-jobs admin REST
 * surface described in `specifications/rest-api-spec.md` §11.3
 * forwards into the persistence layer:
 *
 *   - Pagination axis: `page` (>=1) and `perPage` (1..100). The
 *     ceiling matches {@see LogListQuery} from slice 11a — both are
 *     admin-only diagnostics surfaces where larger pages reduce
 *     click-through cost on incident triage.
 *   - Filter axis: `status` (DB vocabulary closed set) and `jobType`
 *     (closed-set whitelist from `sync-and-cache-strategy.md` §10.1).
 *     Both filters are optional; null means "do not constrain this
 *     axis".
 *
 * Wire ⇔ DB status mapping:
 *
 *   At the wire (REST / UI) the `status` axis uses the vocabulary
 *   `queued|running|completed|failed|cancelled`; at the DB level
 *   (`clb_sync_jobs.status`, see `database-schema.md` §8.2) the
 *   vocabulary is `pending|running|completed|failed|cancelled`. Per
 *   `rest-api-spec.md` §6.0, wire `queued` corresponds to DB
 *   `pending`. This value object exposes the DB-level vocabulary
 *   directly; the REST controller in slice 11e is responsible for
 *   translating wire ⇔ DB so that the persistence layer never has to
 *   reason about the wire-side spelling.
 *
 * Sort axis is intentionally not part of this VO. Spec §11.3 does not
 * expose a sort parameter — the sync-jobs viewer is always
 * newest-first (ORDER BY `created_at DESC, id DESC`), and the
 * repository implementations interpolate that ordering directly.
 *
 * Design notes:
 *
 *   - Both vocabularies are exposed as public class constants
 *     ({@see self::VALID_STATUSES}, {@see self::VALID_JOB_TYPES}) and
 *     as static accessors ({@see self::validStatuses()},
 *     {@see self::validJobTypes()}) so call sites — REST mappers,
 *     admin UI dropdowns, persistence implementations — can enumerate
 *     the accepted set without duplicating the literals.
 *
 *   - Validation is exhaustive at construction time so persistence
 *     implementations may interpolate the filter values directly into
 *     bound SQL parameters without re-validating.
 *
 *   - `jobType` is trimmed at the boundary; whitespace-only inputs
 *     are rejected loudly because the REST layer is responsible for
 *     coercing them to "filter absent" before reaching this VO.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Immutable list-query inputs for {@see SyncJobRepository::findPaginated()}.
 *
 * See the file-level docblock for the spec-driven shape of every axis,
 * the wire ⇔ DB status mapping, and the rationale for exhaustive
 * constructor-time validation.
 */
final class SyncJobListQuery {

	/**
	 * Default per-page count.
	 */
	public const DEFAULT_PER_PAGE = 20;

	/**
	 * Maximum per-page count. Matches {@see LogListQuery::MAX_PER_PAGE}
	 * because both surfaces are admin-only diagnostics views.
	 */
	public const MAX_PER_PAGE = 100;

	/**
	 * Closed-set list of DB-vocabulary status values accepted by the
	 * `status` filter.
	 *
	 * Mirrors `database-schema.md` §8.2 verbatim. Note that wire
	 * `queued` maps to DB `pending` per `rest-api-spec.md` §6.0; the
	 * REST layer is responsible for that translation before reaching
	 * this VO.
	 *
	 * @var list<string>
	 */
	public const VALID_STATUSES = array(
		'pending',
		'running',
		'completed',
		'failed',
		'cancelled',
	);

	/**
	 * Closed-set whitelist of job-type discriminators accepted by the
	 * `jobType` filter.
	 *
	 * Mirrors the catalog declared in
	 * `specifications/sync-and-cache-strategy.md` §10.1.
	 *
	 * @var list<string>
	 */
	public const VALID_JOB_TYPES = array(
		'sync_product',
		'sync_products_bulk',
		'rebuild_render_cache',
		'recalculate_usage_index',
		'cleanup_logs',
		'refresh_provider_health',
	);

	/**
	 * DB-vocabulary status exact-match filter, or null when absent.
	 */
	private ?string $status;

	/**
	 * Job-type exact-match filter, or null when absent.
	 */
	private ?string $jobType;

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

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

	/**
	 * Construct the query with full validation.
	 *
	 * Mirrors the positional-plus-overrides idiom used by
	 * {@see LogListQuery}: every axis has a positional parameter with
	 * a sensible default, and the `$overrides` array lets a
	 * named-keyword call site specify a single axis without restating
	 * the others.
	 *
	 * @param string|null          $status    DB-vocabulary status filter,
	 *                                        or null. One of
	 *                                        `pending|running|completed|failed|cancelled`.
	 * @param string|null          $jobType   Job-type filter, or null.
	 *                                        Must be on the
	 *                                        {@see self::VALID_JOB_TYPES}
	 *                                        whitelist.
	 * @param int                  $page      1-based page number (>=1).
	 * @param int                  $perPage   Records per page
	 *                                        (1..MAX_PER_PAGE).
	 * @param array<string, mixed> $overrides Named-keyword overrides
	 *                                        keyed by `status` /
	 *                                        `jobType` / `page` /
	 *                                        `perPage`. Unknown keys
	 *                                        are ignored so callers
	 *                                        can pass request payloads
	 *                                        verbatim without
	 *                                        filtering.
	 *
	 * @throws InvalidArgumentException When any axis is out of range
	 *                                  or off-whitelist.
	 */
	public function __construct(
		?string $status = null,
		?string $jobType = null,
		int $page = 1,
		int $perPage = self::DEFAULT_PER_PAGE,
		array $overrides = array()
	) {
		if ( array_key_exists( 'status', $overrides ) ) {
			/** @var string|null $status */
			$status = $overrides['status'];
		}
		if ( array_key_exists( 'jobType', $overrides ) ) {
			/** @var string|null $jobType */
			$jobType = $overrides['jobType'];
		}
		if ( array_key_exists( 'page', $overrides ) ) {
			/** @var int $page */
			$page = (int) $overrides['page'];
		}
		if ( array_key_exists( 'perPage', $overrides ) ) {
			/** @var int $perPage */
			$perPage = (int) $overrides['perPage'];
		}

		if ( $status !== null && ! in_array( $status, self::VALID_STATUSES, true ) ) {
			$message = sprintf(
				'SyncJobListQuery: status "%s" is not a DB-vocabulary value [%s]. '
				. 'Wire-vocabulary "queued" must be mapped to DB "pending" by the REST layer '
				. 'per rest-api-spec.md §6.0.',
				$status,
				implode( ', ', self::VALID_STATUSES )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $jobType !== null ) {
			$trimmed = trim( $jobType );
			if ( $trimmed === '' ) {
				$message = 'SyncJobListQuery: jobType 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 ( ! in_array( $trimmed, self::VALID_JOB_TYPES, true ) ) {
				$message = sprintf(
					'SyncJobListQuery: jobType "%s" is not on the whitelist [%s] '
					. '(see sync-and-cache-strategy.md §10.1).',
					$trimmed,
					implode( ', ', self::VALID_JOB_TYPES )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$jobType = $trimmed;
		}

		if ( $page < 1 ) {
			$message = sprintf( 'SyncJobListQuery: 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(
				'SyncJobListQuery: 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 );
		}

		$this->status  = $status;
		$this->jobType = $jobType;
		$this->page    = $page;
		$this->perPage = $perPage;
	}

	/**
	 * Default query: page 1, 20 per page, no filters.
	 */
	public static function defaults(): self {
		return new self();
	}

	/**
	 * Closed-set list of DB-vocabulary status values accepted by the
	 * `status` filter.
	 *
	 * @return list<string>
	 */
	public static function validStatuses(): array {
		return self::VALID_STATUSES;
	}

	/**
	 * Closed-set whitelist of job-type discriminators accepted by the
	 * `jobType` filter.
	 *
	 * @return list<string>
	 */
	public static function validJobTypes(): array {
		return self::VALID_JOB_TYPES;
	}

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

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

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

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

	/**
	 * Zero-based offset into the result set, derived from `page` and
	 * `perPage`.
	 *
	 * Convenience accessor for repository implementations that map
	 * the pagination axis to a SQL `LIMIT $perPage OFFSET $offset`
	 * pair.
	 */
	public function offset(): int {
		return ( $this->page - 1 ) * $this->perPage;
	}

	/**
	 * 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(
			$this->status,
			$this->jobType,
			$page,
			$this->perPage
		);
	}

	/**
	 * Return a new query with the given per-page count.
	 *
	 * @param int $perPage Records per page (1..MAX_PER_PAGE).
	 *
	 * @throws InvalidArgumentException When `$perPage` is out of range.
	 */
	public function withPerPage( int $perPage ): self {
		return new self(
			$this->status,
			$this->jobType,
			$this->page,
			$perPage
		);
	}

	/**
	 * Deep equality against another query.
	 *
	 * @param self $other Query to compare.
	 */
	public function equals( self $other ): bool {
		return $this->status === $other->status
			&& $this->jobType === $other->jobType
			&& $this->page === $other->page
			&& $this->perPage === $other->perPage;
	}
}
