<?php
/**
 * Immutable value object for paginated sync-job listing results.
 *
 * Carries the row slice and pagination totals returned by
 * {@see SyncJobRepository::findPaginated()}, shaped to align with the
 * REST list-response envelope described in
 * `specifications/rest-api-spec.md` §11.3:
 *
 *   - `items`: the page-sized slice of {@see SyncJobRecord} rows that
 *     matched the query, ordered newest-first by the repository.
 *   - `total`: the total row count across every page (after
 *     filtering, before pagination).
 *   - `page` / `perPage`: echoed back from the originating
 *     {@see SyncJobListQuery} so consumers can reconstruct the
 *     pagination envelope without retaining the query object.
 *
 * Design notes:
 *
 *   - `pageCount()` is computed on demand from `total` / `perPage`.
 *     When `total` is zero the conventional empty-result shape
 *     returns zero pages — there is no "page 1 of 0 items" — so
 *     REST consumers can render an empty state without a `1 / 0`
 *     artifact. This mirrors the {@see LogListResult::pageCount()}
 *     semantics established in slice 11a.
 *
 *   - The result intentionally does NOT carry the originating
 *     {@see SyncJobListQuery}. Persistence implementations that want
 *     to surface the query in their telemetry can do so out-of-band;
 *     this VO stays narrow so it round-trips cleanly through any
 *     REST mapper without dragging filter inputs into the response.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Immutable result envelope for {@see SyncJobRepository::findPaginated()}.
 *
 * See the file-level docblock for the `pageCount()` semantics and the
 * rationale for not carrying the originating query.
 */
final class SyncJobListResult {

	/**
	 * Page slice, in newest-first order produced by the repository.
	 *
	 * @var list<SyncJobRecord>
	 */
	private array $items;

	/**
	 * Total row count across every page (after filtering, before
	 * pagination).
	 */
	private int $total;

	/**
	 * 1-based page number echoed back from the originating query.
	 */
	private int $page;

	/**
	 * Records per page echoed back from the originating query.
	 */
	private int $perPage;

	/**
	 * Construct the result envelope with full validation.
	 *
	 * @param list<SyncJobRecord> $items   Page slice.
	 * @param int                 $total   Total matching rows (>= 0).
	 * @param int                 $page    1-based page number (>= 1).
	 * @param int                 $perPage Records per page (>= 1).
	 *
	 * @throws InvalidArgumentException When `$items` is not a list of
	 *                                  {@see SyncJobRecord}, when
	 *                                  `$total` is negative, or when
	 *                                  `$page` / `$perPage` are below
	 *                                  1.
	 */
	public function __construct( array $items, int $total, int $page, int $perPage ) {
		$expectedIndex = 0;
		foreach ( $items as $key => $item ) {
			if ( $key !== $expectedIndex ) {
				$message = 'SyncJobListResult: items must be a 0-indexed list.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			if ( ! $item instanceof SyncJobRecord ) {
				$message = 'SyncJobListResult: every items entry must be a SyncJobRecord.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			++$expectedIndex;
		}

		if ( $total < 0 ) {
			$message = sprintf( 'SyncJobListResult: total must be >= 0, got %d.', $total );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $page < 1 ) {
			$message = sprintf( 'SyncJobListResult: 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 ) {
			$message = sprintf( 'SyncJobListResult: perPage must be >= 1, got %d.', $perPage );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		/** @var list<SyncJobRecord> $items */
		$this->items   = $items;
		$this->total   = $total;
		$this->page    = $page;
		$this->perPage = $perPage;
	}

	/**
	 * Page slice, in newest-first order produced by the repository.
	 *
	 * @return list<SyncJobRecord>
	 */
	public function items(): array {
		return $this->items;
	}

	/**
	 * Total row count across every page.
	 */
	public function total(): int {
		return $this->total;
	}

	/**
	 * 1-based page number echoed back from the originating query.
	 */
	public function page(): int {
		return $this->page;
	}

	/**
	 * Records per page echoed back from the originating query.
	 */
	public function perPage(): int {
		return $this->perPage;
	}

	/**
	 * Total number of pages spanned by `total` at `perPage`
	 * granularity.
	 *
	 * Returns 0 when `total` is 0 (the conventional empty-result
	 * shape) and `(int) ceil($total / $perPage)` otherwise.
	 */
	public function pageCount(): int {
		if ( $this->total === 0 ) {
			return 0;
		}

		return (int) ceil( $this->total / $this->perPage );
	}

	/**
	 * Whether the result envelope carries no rows.
	 */
	public function isEmpty(): bool {
		return $this->total === 0;
	}
}
