<?php
/**
 * Audit-event list-query result envelope.
 *
 * Phase 2 (KI-006) immutable value object that carries one page
 * of {@see AuditEventRecord} rows alongside the pagination
 * metadata `GET /clb/v1/audit/events` per
 * `specifications/rest-api-spec.md` §12.4 returns to its
 * callers. Mirrors the {@see LogListResult} shape so the audit
 * list path reuses the same envelope conventions the §12.2 logs
 * surface already established.
 *
 * Field semantics:
 *
 *   - `items` — the page slice, in newest-first order produced
 *     by the repository (`ORDER BY created_at DESC, id DESC`).
 *     Always a `list<AuditEventRecord>`; the constructor
 *     enforces sequential 0-based integer keys so callers can
 *     iterate without re-indexing.
 *
 *   - `total` — total row count across every page after the
 *     filter axes have been applied but BEFORE pagination, so
 *     REST consumers can compute the page count without
 *     re-issuing the query.
 *
 *   - `page` / `perPage` — echoed back from the originating
 *     {@see AuditEventListQuery} so consumers do not have to
 *     hold the request VO alongside the response envelope to
 *     interpret the slice.
 *
 * Convenience derivations:
 *
 *   - {@see self::pageCount()} returns `(int) ceil($total / $perPage)`
 *     for non-empty results and 0 when `$total === 0`. This is
 *     the same shape Phase 11 admin pagination renderers consume
 *     from {@see LogListResult::pageCount()} so the audit page
 *     can be wired through the same UI helper without a custom
 *     adapter.
 *
 *   - {@see self::isEmpty()} reports whether the envelope
 *     carries zero rows across every page (not just the current
 *     slice), so an empty-state renderer can branch on the
 *     filter outcome without inspecting `count(items())`.
 *
 * Failure semantics:
 *
 *   - Validation failures raise {@see InvalidArgumentException}
 *     at construction time so a programming error in the
 *     repository (non-list `items`, mis-typed entry,
 *     out-of-range pagination axis) surfaces loudly rather than
 *     reaching the REST envelope serializer.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Immutable result envelope for the §12.4 audit-events listing.
 *
 * See the file-level docblock for the field semantics, the
 * convenience derivations ({@see self::pageCount()},
 * {@see self::isEmpty()}), and the validation invariants the
 * constructor enforces.
 */
final class AuditEventListResult {

	/**
	 * Page slice, in newest-first order produced by the repository.
	 *
	 * @var list<AuditEventRecord>
	 */
	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 a result envelope from the page slice and the
	 * pagination axes echoed back from the originating
	 * {@see AuditEventListQuery}.
	 *
	 * Validation:
	 *
	 *   - `$items` MUST be a `list<AuditEventRecord>`: sequential
	 *     0-based integer keys, every entry an
	 *     {@see AuditEventRecord} instance.
	 *
	 *   - `$total` MUST be `>= 0`. A non-zero `$total` with an
	 *     empty `$items` slice is permitted because the caller
	 *     may have requested a page beyond the last populated
	 *     slice.
	 *
	 *   - `$page` MUST be `>= 1`. `$perPage` MUST be `>= 1`.
	 *
	 * @param list<AuditEventRecord> $items   Page slice.
	 * @param int                    $total   Total row count.
	 * @param int                    $page    1-based page number.
	 * @param int                    $perPage Records per page.
	 *
	 * @throws InvalidArgumentException When `$items` is not a
	 *                                  `list<AuditEventRecord>`,
	 *                                  when `$total < 0`, or when
	 *                                  either `$page` or
	 *                                  `$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 = sprintf(
					'AuditEventListResult: items must be a sequential list (0-based integer keys); '
					. 'got key %s at position %d.',
					// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- Developer-facing exception needs exact invalid array key representation.
					var_export( $key, true ),
					$expectedIndex
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				throw new InvalidArgumentException( $message );
			}
			if ( ! $item instanceof AuditEventRecord ) {
				$message = sprintf(
					'AuditEventListResult: items[%d] must be an AuditEventRecord; got %s.',
					$expectedIndex,
					is_object( $item ) ? get_class( $item ) : gettype( $item )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				throw new InvalidArgumentException( $message );
			}
			++$expectedIndex;
		}

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

		if ( $page < 1 ) {
			$message = sprintf(
				'AuditEventListResult: page must be >= 1; got %d.',
				$page
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		if ( $perPage < 1 ) {
			$message = sprintf(
				'AuditEventListResult: perPage must be >= 1; got %d.',
				$perPage
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		/** @var list<AuditEventRecord> $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<AuditEventRecord>
	 */
	public function items(): array {
		return $this->items;
	}

	/**
	 * Total row count across every page (after filtering, before
	 * pagination).
	 */
	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 the result envelope spans.
	 *
	 * Returns 0 when `$total === 0` (the documented empty-state
	 * 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 across every
	 * page (not just the current slice).
	 */
	public function isEmpty(): bool {
		return $this->total === 0;
	}
}
