<?php
/**
 * Audit-event list-query value object.
 *
 * Phase 2 (KI-006) immutable VO that carries the validated query
 * surface for `GET /affilun/v1/audit/events` per
 * `specifications/rest-api-spec.md` §12.4. Mirrors the
 * {@see LogListQuery} shape so the audit list path reuses the
 * same pagination / filter conventions the §12.2 logs surface
 * already established.
 *
 * Filter axes (per §12.4):
 *
 *   - `auditKind` — exact-match filter against the §10.2
 *     `audit_kind` discriminator. The MVP enforces a closed
 *     vocabulary ({@see self::VALID_AUDIT_KINDS}) so a tampered
 *     query parameter cannot inject an arbitrary token into the
 *     prepared SQL filter; values outside the whitelist raise
 *     {@see InvalidArgumentException} at construction time.
 *
 *   - `actorId` — exact-match filter against the §10.2 `actor_id`
 *     column. Held as a string because the column itself is
 *     `varchar(64)` and accepts non-numeric ids (worker ids,
 *     `system`-shaped tokens).
 *
 *   - `targetType` — exact-match filter against the §10.2
 *     `target_type` column. Open string per the contract: §10.2
 *     does not declare a closed enum for target classifications
 *     and a future slice may register additional types.
 *
 *   - `targetId` — exact-match filter against the §10.2
 *     `target_id` column. Held as a string for the same reason
 *     as `actorId` (varchar id space).
 *
 *   - `page` — 1-based page number (>= 1).
 *
 *   - `perPage` — page size, bounded by
 *     {@see self::DEFAULT_PER_PAGE} / {@see self::MAX_PER_PAGE}.
 *     Wider than the public products list (50 in §5.1) because
 *     the audit viewer is an admin-only diagnostics surface,
 *     matching {@see LogListQuery}'s policy.
 *
 * Out of scope (deferred):
 *
 *   - `request_id` / `correlation_id` / `job_id` filters from
 *     §12.4 (Phase 2 KI-006 follow-up): exposed as the
 *     {@see self::requestId()} / {@see self::correlationId()} /
 *     {@see self::jobId()} accessors. These three values are
 *     stored inside `context_json` rather than first-class
 *     columns, so the persistence layer honors them via either a
 *     `JSON_EXTRACT()` query (Wpdb implementation) or a
 *     JSON-decode + key-lookup pass (in-memory implementation).
 *     Original out-of-scope note follows for historical context.
 *     The honoring path requires either a `JSON_EXTRACT()` query
 *     or a denormalized index column. Both
 *     options are larger than the KI-006 closeout warrants and
 *     are tracked as a follow-up alongside the §12.5 summary
 *     endpoint.
 *
 * Failure semantics:
 *
 *   - Every validation failure raises
 *     {@see InvalidArgumentException} at construction time so a
 *     caller observing a `LogListQuery` shape (REST controller,
 *     admin page) can route the failure through one catch
 *     boundary without re-validating the same axes downstream.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Validated query surface for the §12.4 audit-events listing.
 *
 * See the file-level docblock for the per-axis vocabulary, the
 * pagination bounds, and the deferred `request_id` /
 * `correlation_id` / `job_id` filters.
 */
final class AuditEventListQuery {

	/**
	 * Default per-page count when the caller does not supply
	 * `per_page`. Mirrors {@see LogListQuery::DEFAULT_PER_PAGE}.
	 */
	public const DEFAULT_PER_PAGE = 20;

	/**
	 * Maximum per-page count the audit list accepts. Mirrors the
	 * {@see LogListQuery} ceiling (100) because the audit viewer
	 * is an admin-only diagnostics surface, wider than the public
	 * products list ceiling of 50.
	 */
	public const MAX_PER_PAGE = 100;

	/**
	 * Closed vocabulary for the §10.2 `audit_kind` filter axis.
	 *
	 * Held as a public class constant so REST controllers,
	 * admin-page parsers, diagnostics surfaces, and tests share
	 * one source of truth.
	 *
	 * @var list<string>
	 */
	public const VALID_AUDIT_KINDS = array(
		'configuration_change',
		'destructive_action',
		'security_event',
		'operational_action',
		'compliance_relevant_action',
	);

	/**
	 * Optional `audit_kind` exact-match filter.
	 */
	private ?string $auditKind;

	/**
	 * Optional `actor_id` exact-match filter.
	 */
	private ?string $actorId;

	/**
	 * Optional `target_type` exact-match filter.
	 */
	private ?string $targetType;

	/**
	 * Optional `target_id` exact-match filter.
	 */
	private ?string $targetId;

	/**
	 * 1-based page number (>= 1).
	 */
	private int $page;

	/**
	 * Page size (1 .. {@see self::MAX_PER_PAGE}).
	 */
	private int $perPage;

	/**
	 * Optional `context_json.$.request_id` exact-match filter.
	 *
	 * Phase 2 KI-006 follow-up: §12.4 lists `request_id` as a
	 * supported query parameter. The value is not a first-class
	 * column on `affilun_audit_events`; the persistence layer honors
	 * the filter via a JSON-key lookup (`JSON_EXTRACT` for the
	 * Wpdb implementation, decode + key-match for the in-memory
	 * implementation).
	 */
	private ?string $requestId;

	/**
	 * Optional `context_json.$.correlation_id` exact-match filter.
	 *
	 * Phase 2 KI-006 follow-up — see {@see self::$requestId} for
	 * the persistence-side honoring contract.
	 */
	private ?string $correlationId;

	/**
	 * Optional `context_json.$.job_id` exact-match filter.
	 *
	 * Phase 2 KI-006 follow-up — see {@see self::$requestId} for
	 * the persistence-side honoring contract. Particularly useful
	 * for "show me every audit row produced by sync job N" drill-
	 * downs since {@see \Affilun\Application\Audit\AuditEventDraft::forJobAction()}
	 * auto-populates `context_json.job_id`.
	 */
	private ?string $jobId;

	/**
	 * Construct a validated audit-events list query.
	 *
	 * Every axis is optional. Filter strings are trimmed; an
	 * empty trimmed value is treated as "no filter" rather than
	 * "empty-string filter" so a stray `?actor_id=` query
	 * parameter does not silently match every row whose actor id
	 * is the empty string.
	 *
	 * The Phase 2 KI-006 follow-up `requestId` / `correlationId` /
	 * `jobId` axes are appended AFTER `page` / `perPage` to
	 * preserve every existing positional caller — both the page
	 * pair and the four first-class axes were positional before
	 * the follow-up, and re-shuffling them would silently change
	 * the meaning of unnamed callers.
	 *
	 * @param string|null $auditKind     §10.2 audit-kind filter; rejected
	 *                                   when off-whitelist.
	 * @param string|null $actorId       §10.2 actor-id filter.
	 * @param string|null $targetType    §10.2 target-type filter.
	 * @param string|null $targetId      §10.2 target-id filter.
	 * @param int         $page          1-based page number.
	 * @param int         $perPage       Page size (1 .. MAX_PER_PAGE).
	 * @param string|null $requestId     §12.4 `context_json.$.request_id`
	 *                                   filter (Phase 2 follow-up).
	 * @param string|null $correlationId §12.4 `context_json.$.correlation_id`
	 *                                   filter (Phase 2 follow-up).
	 * @param string|null $jobId         §12.4 `context_json.$.job_id`
	 *                                   filter (Phase 2 follow-up).
	 *
	 * @throws InvalidArgumentException When `$auditKind` is off-whitelist,
	 *                                  when `$page < 1`, or when
	 *                                  `$perPage` is out of range.
	 */
	public function __construct(
		?string $auditKind = null,
		?string $actorId = null,
		?string $targetType = null,
		?string $targetId = null,
		int $page = 1,
		int $perPage = self::DEFAULT_PER_PAGE,
		?string $requestId = null,
		?string $correlationId = null,
		?string $jobId = null
	) {
		$normalizedAuditKind     = self::normalizeOptional( $auditKind );
		$normalizedActorId       = self::normalizeOptional( $actorId );
		$normalizedTargetType    = self::normalizeOptional( $targetType );
		$normalizedTargetId      = self::normalizeOptional( $targetId );
		$normalizedRequestId     = self::normalizeOptional( $requestId );
		$normalizedCorrelationId = self::normalizeOptional( $correlationId );
		$normalizedJobId         = self::normalizeOptional( $jobId );

		if (
			$normalizedAuditKind !== null
			&& ! in_array( $normalizedAuditKind, self::VALID_AUDIT_KINDS, true )
		) {
			$message = sprintf(
				'AuditEventListQuery: auditKind must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_AUDIT_KINDS ),
				$normalizedAuditKind
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		if ( $page < 1 ) {
			$message = sprintf(
				'AuditEventListQuery: 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 || $perPage > self::MAX_PER_PAGE ) {
			$message = sprintf(
				'AuditEventListQuery: perPage must be 1..%d; got %d.',
				self::MAX_PER_PAGE,
				$perPage
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		$this->auditKind     = $normalizedAuditKind;
		$this->actorId       = $normalizedActorId;
		$this->targetType    = $normalizedTargetType;
		$this->targetId      = $normalizedTargetId;
		$this->page          = $page;
		$this->perPage       = $perPage;
		$this->requestId     = $normalizedRequestId;
		$this->correlationId = $normalizedCorrelationId;
		$this->jobId         = $normalizedJobId;
	}

	/**
	 * Closed §10.2 audit-kind vocabulary used by REST validators
	 * and admin-page parsers that need to enumerate the accepted
	 * filter values.
	 *
	 * @return list<string>
	 */
	public static function validAuditKinds(): array {
		return self::VALID_AUDIT_KINDS;
	}

	/**
	 * Default query: page 1, {@see self::DEFAULT_PER_PAGE} per
	 * page, no filters on any axis (including the Phase 2
	 * KI-006 follow-up `requestId` / `correlationId` / `jobId`
	 * context filters).
	 *
	 * Used by the admin-page query parser as a graceful-fallback
	 * value when a tampered or stale `$_GET` URL would otherwise
	 * raise from {@see self::__construct()}; the admin page then
	 * still renders a usable view rather than fataling on a
	 * bookmarked URL that survived a vocabulary tweak.
	 *
	 * Mirrors {@see \Affilun\Infrastructure\Persistence\LogListQuery::defaults()}
	 * so the §6 Logs admin page and the (Phase 2 follow-up) §10
	 * Audit Events admin page share the same fallback contract.
	 */
	public static function defaults(): self {
		return new self();
	}

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

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

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

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

	/**
	 * Optional `context_json.$.request_id` exact-match filter,
	 * or null when absent.
	 *
	 * Phase 2 KI-006 follow-up.
	 */
	public function requestId(): ?string {
		return $this->requestId;
	}

	/**
	 * Optional `context_json.$.correlation_id` exact-match filter,
	 * or null when absent.
	 *
	 * Phase 2 KI-006 follow-up.
	 */
	public function correlationId(): ?string {
		return $this->correlationId;
	}

	/**
	 * Optional `context_json.$.job_id` exact-match filter, or
	 * null when absent.
	 *
	 * Phase 2 KI-006 follow-up.
	 */
	public function jobId(): ?string {
		return $this->jobId;
	}

	/**
	 * 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 row offset derived from
	 * `(page - 1) * perPage`. Convenience accessor so the
	 * repository's `LIMIT` / `OFFSET` clause does not have to
	 * re-derive the value from the `(page, perPage)` pair.
	 */
	public function offset(): int {
		return ( $this->page - 1 ) * $this->perPage;
	}

	/**
	 * Produce a copy with the given `$page` value.
	 *
	 * @param int $page 1-based page number (>= 1).
	 *
	 * @throws InvalidArgumentException When `$page < 1`.
	 */
	public function withPage( int $page ): self {
		return new self(
			$this->auditKind,
			$this->actorId,
			$this->targetType,
			$this->targetId,
			$page,
			$this->perPage,
			$this->requestId,
			$this->correlationId,
			$this->jobId
		);
	}

	/**
	 * Produce a copy with the given `$perPage` value.
	 *
	 * @param int $perPage Page size (1 .. MAX_PER_PAGE).
	 *
	 * @throws InvalidArgumentException When `$perPage` is out of range.
	 */
	public function withPerPage( int $perPage ): self {
		return new self(
			$this->auditKind,
			$this->actorId,
			$this->targetType,
			$this->targetId,
			$this->page,
			$perPage,
			$this->requestId,
			$this->correlationId,
			$this->jobId
		);
	}

	/**
	 * Whether this query is structurally identical to `$other`.
	 *
	 * @param self $other Query to compare.
	 */
	public function equals( self $other ): bool {
		return $this->auditKind === $other->auditKind
			&& $this->actorId === $other->actorId
			&& $this->targetType === $other->targetType
			&& $this->targetId === $other->targetId
			&& $this->requestId === $other->requestId
			&& $this->correlationId === $other->correlationId
			&& $this->jobId === $other->jobId
			&& $this->page === $other->page
			&& $this->perPage === $other->perPage;
	}

	/**
	 * Trim an optional string filter, returning null when the
	 * trimmed result is empty so a stray `?key=` query parameter
	 * is treated as "no filter" rather than "empty-string filter".
	 *
	 * @param string|null $value Raw filter value.
	 */
	private static function normalizeOptional( ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}
		$trimmed = trim( $value );
		return $trimmed === '' ? null : $trimmed;
	}
}
