<?php
/**
 * Immutable value object for paginated log listing queries.
 *
 * Encapsulates the column-level inputs the diagnostics / logs admin
 * REST surface described in `specifications/rest-api-spec.md` §12.2
 * forwards into the persistence layer:
 *
 *   - Pagination axis: `page` (>=1) and `perPage` (1..100). The
 *     ceiling is wider than the public products list (50 in §5.1)
 *     because the logs viewer is an admin-only diagnostics surface
 *     where larger pages reduce click-through cost on incident
 *     triage.
 *   - Filter axis: `level` (PSR-3 closed set), `channel` (exact match),
 *     `relatedProductId` and `relatedJobId` (positive ids). All
 *     filters are optional; null means "do not constrain this axis".
 *
 * Spec §12.2 also lists `event_type`, `request_id`, `correlation_id`,
 * and `job_id` query parameters. Those four filters are deliberately
 * NOT exposed on this VO yet — they require JSON-extraction queries
 * against the `context_json` column (see `database-schema.md` §9 and
 * the {@see LogRecord} field-level docblock for why those structured
 * fields live inside the JSON blob rather than as dedicated columns).
 * The Phase 11 MVP exposes only the column-level filters; the JSON
 * filters are tracked as a Phase 11 / 12 follow-up so the logs admin
 * page can ship with column-filter support without waiting on the
 * JSON-extraction query plumbing.
 *
 * Sort axis is intentionally not part of this VO. Spec §12.2 does not
 * expose a sort parameter — the logs viewer is always newest-first
 * (ORDER BY `created_at DESC, id DESC`), and the repository
 * implementations interpolate that ordering directly. Reintroducing a
 * sort axis is a Phase 11 / 12 follow-up if the admin UX needs it.
 *
 * Design notes:
 *
 *   - The PSR-3 level vocabulary is exposed as both a public class
 *     constant array ({@see self::VALID_LEVELS}) and as a static
 *     accessor ({@see self::validLevels()}) 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 `level` directly into bound
 *     SQL parameters without re-validating: by the time a query
 *     reaches the repository, the level (when non-null) 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.
 *
 *   - `channel` is a nullable string; when supplied, it 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 CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Immutable list-query inputs for {@see LogRepository::findPaginated()}.
 *
 * See the file-level docblock for the spec-driven shape of every axis,
 * the deferred JSON filter set tracked for Phase 11 / 12, and the
 * rationale for exhaustive constructor-time validation.
 */
final class LogListQuery {

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

	/**
	 * Maximum per-page count. Wider than the public products list
	 * (50 in §5.1) because the logs viewer is an admin-only
	 * diagnostics surface.
	 */
	public const MAX_PER_PAGE = 100;

	/**
	 * Closed-set list of PSR-3 severity levels accepted by the
	 * `level` filter.
	 *
	 * Mirrors the PSR-3 `LogLevel` constants verbatim so call sites
	 * can validate against this list without importing the PSR-3
	 * package at the persistence layer.
	 *
	 * @var list<string>
	 */
	public const VALID_LEVELS = array(
		'emergency',
		'alert',
		'critical',
		'error',
		'warning',
		'notice',
		'info',
		'debug',
	);

	/**
	 * PSR-3 level exact-match filter, or null when absent.
	 */
	private ?string $level;

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

	/**
	 * `clb_logs.related_product_id` filter, or null when absent.
	 */
	private ?int $relatedProductId;

	/**
	 * `clb_logs.related_job_id` filter, or null when absent.
	 */
	private ?int $relatedJobId;

	/**
	 * 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 ProductListQuery}: 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          $level            PSR-3 level filter, or null.
	 * @param string|null          $channel          Channel filter, or null.
	 * @param int|null             $relatedProductId Related product id filter (>=1), or null.
	 * @param int|null             $relatedJobId     Related job id filter (>=1), or null.
	 * @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 `level` / `channel` /
	 *                                               `relatedProductId` /
	 *                                               `relatedJobId` / `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 $level = null,
		?string $channel = null,
		?int $relatedProductId = null,
		?int $relatedJobId = null,
		int $page = 1,
		int $perPage = self::DEFAULT_PER_PAGE,
		array $overrides = array()
	) {
		if ( array_key_exists( 'level', $overrides ) ) {
			/** @var string|null $level */
			$level = $overrides['level'];
		}
		if ( array_key_exists( 'channel', $overrides ) ) {
			/** @var string|null $channel */
			$channel = $overrides['channel'];
		}
		if ( array_key_exists( 'relatedProductId', $overrides ) ) {
			/** @var int|null $relatedProductId */
			$relatedProductId = $overrides['relatedProductId'];
		}
		if ( array_key_exists( 'relatedJobId', $overrides ) ) {
			/** @var int|null $relatedJobId */
			$relatedJobId = $overrides['relatedJobId'];
		}
		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 ( $level !== null && ! in_array( $level, self::VALID_LEVELS, true ) ) {
			$message = sprintf(
				'LogListQuery: level "%s" is not a PSR-3 level [%s].',
				$level,
				implode( ', ', self::VALID_LEVELS )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

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

		if ( $page < 1 ) {
			$message = sprintf( 'LogListQuery: 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(
				'LogListQuery: 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->level            = $level;
		$this->channel          = $channel;
		$this->relatedProductId = $relatedProductId;
		$this->relatedJobId     = $relatedJobId;
		$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 PSR-3 levels accepted by the `level` filter.
	 *
	 * Static accessor mirroring the {@see ProductListQuery::validXxx()}
	 * idiom so call sites can enumerate the vocabulary without
	 * referencing the constant directly.
	 *
	 * @return list<string>
	 */
	public static function validLevels(): array {
		return self::VALID_LEVELS;
	}

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

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

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

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

	/**
	 * 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->level,
			$this->channel,
			$this->relatedProductId,
			$this->relatedJobId,
			$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->level,
			$this->channel,
			$this->relatedProductId,
			$this->relatedJobId,
			$this->page,
			$perPage
		);
	}

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