<?php
/**
 * Immutable value object for paginated product listing results.
 *
 * Carries the row slice and pagination totals returned by
 * {@see ProductRepository::findPaginated()}, shaped to align with the
 * REST list-response envelope described in
 * `specifications/rest-api-spec.md` §4.2:
 *
 *   - `items`: the page-sized slice of {@see ProductRecord} rows that
 *     matched the query, ordered by the query's sort axis.
 *   - `totalItems`: the total row count across every page (after
 *     filtering, before pagination).
 *   - `page` / `perPage`: echoed back from the originating
 *     {@see ProductListQuery} so consumers can reconstruct the
 *     pagination envelope without retaining the query object.
 *
 * Design notes:
 *
 *   - `totalPages()` is computed on demand from `totalItems` /
 *     `perPage`. The §4.2 example (`total_items=135`, `per_page=20`,
 *     `total_pages=7`) is a plain `ceil()`. When `totalItems` 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.
 *
 *   - The result intentionally does NOT carry the originating
 *     {@see ProductListQuery}. 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 Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;

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

	/**
	 * Page slice, in the order produced by the query's sort axis.
	 *
	 * @var list<ProductRecord>
	 */
	private array $items;

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

	/**
	 * 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<ProductRecord> $items      Page slice.
	 * @param int                 $totalItems 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 ProductRecord}, when
	 *                                  `$totalItems` is negative, or
	 *                                  when `$page` / `$perPage` are
	 *                                  below 1.
	 */
	public function __construct( array $items, int $totalItems, int $page, int $perPage ) {
		$expectedIndex = 0;
		foreach ( $items as $key => $item ) {
			if ( $key !== $expectedIndex ) {
				$message = 'ProductListResult: 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 ProductRecord ) {
				$message = 'ProductListResult: every items entry must be a ProductRecord.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			++$expectedIndex;
		}

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

		if ( $page < 1 ) {
			$message = sprintf( 'ProductListResult: 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( 'ProductListResult: 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<ProductRecord> $items */
		$this->items      = $items;
		$this->totalItems = $totalItems;
		$this->page       = $page;
		$this->perPage    = $perPage;
	}

	/**
	 * Page slice, in the order produced by the query's sort axis.
	 *
	 * @return list<ProductRecord>
	 */
	public function items(): array {
		return $this->items;
	}

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

	/**
	 * 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 `totalItems` at `perPage`
	 * granularity.
	 *
	 * Returns 0 when `totalItems` is 0 (the conventional empty-result
	 * shape) and `(int) ceil($totalItems / $perPage)` otherwise. The
	 * §4.2 example aligns: `135 / 20 → 7`.
	 */
	public function totalPages(): int {
		if ( $this->totalItems === 0 ) {
			return 0;
		}

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

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

	/**
	 * Deep equality against another result envelope.
	 *
	 * Items are compared element-wise via
	 * {@see ProductRecord::toArray()}, so two records carrying the
	 * same column payload compare equal even when they are distinct
	 * object instances.
	 *
	 * @param self $other Result to compare.
	 */
	public function equals( self $other ): bool {
		if ( $this->totalItems !== $other->totalItems ) {
			return false;
		}
		if ( $this->page !== $other->page ) {
			return false;
		}
		if ( $this->perPage !== $other->perPage ) {
			return false;
		}
		if ( count( $this->items ) !== count( $other->items ) ) {
			return false;
		}

		foreach ( $this->items as $index => $item ) {
			if ( $item->toArray() !== $other->items[ $index ]->toArray() ) {
				return false;
			}
		}

		return true;
	}
}
