<?php
/**
 * Audit retention purge result value object.
 *
 * Phase 2 (KI-006 follow-up) immutable value object that
 * captures the outcome of one
 * {@see \Affilun\Application\Audit\AuditRetentionPurger::purge()}
 * invocation. Carried back to the surrounding scheduler (WP-Cron,
 * CLI, integration test) so it can fold per-tick metrics into
 * observability surfaces (logs, audit events, diagnostics
 * counters) and so the purger itself can stamp the matching
 * `affilun_audit_events` audit row that records the §10.4
 * "purge 実行自体も audit または operational event として記録" rule.
 *
 * Field semantics:
 *
 *   - `tickStartedAt` — the UTC instant the purger stamped at
 *     the top of the tick. This is the same value the policy
 *     used to derive the cutoff, so a downstream observer can
 *     reconstruct the policy arithmetic without re-reading the
 *     clock.
 *
 *   - `cutoff` — the strict-less-than UTC cutoff the policy
 *     produced from {@see tickStartedAt}. Rows whose
 *     `created_at` is strictly less than the cutoff were
 *     removed; rows whose `created_at` equals the cutoff were
 *     preserved per the
 *     {@see \Affilun\Infrastructure\Persistence\AuditEventRepository::deleteOlderThan()}
 *     contract.
 *
 *   - `retentionDays` — the configured retention window in days
 *     the policy was using when the purger ran. Echoed back so
 *     the audit row recording the purge can carry the policy
 *     state at the moment of the sweep, even if a later
 *     reconfiguration changes the live policy.
 *
 *   - `deleted` — the number of rows the persistence layer
 *     reports as removed (`>= 0`). A value of `0` is the steady-
 *     state "nothing to purge" outcome the §10.4 worker observes
 *     on most ticks once the table is older than the retention
 *     window — it is NOT an error and the worker still emits its
 *     audit row so the observability surface can show "purge ran
 *     and found nothing to do" rather than "purge skipped".
 *
 * Invariants enforced by the constructor:
 *
 *   - `deleted >= 0` (a non-negative count is the only meaningful
 *     value for a delete operation; negative inputs are coerced
 *     defensively at the persistence boundary, but the VO also
 *     refuses them so a hostile / mocked repository cannot
 *     smuggle a bogus value into the audit payload).
 *
 *   - `retentionDays >= 1` (the policy enforces a 30-day floor
 *     itself; the VO accepts anything `>= 1` so tests that drive
 *     the result directly do not have to construct a full policy
 *     just to satisfy a transitive minimum).
 *
 *   - `tickStartedAt` and `cutoff` are normalized to UTC so the
 *     audit row this result feeds carries timestamps in the same
 *     timezone the persistence layer stores them in.
 *
 * Mirrors the {@see \Affilun\Application\Sync\SyncWorkerTickResult}
 * shape (compact summary VO carried back to the surrounding
 * scheduler) so the §10.4 retention purge surface reuses the
 * same per-tick observability conventions the §11 sync worker
 * already established.
 *
 * @package Affilun\Application\Audit
 */

declare(strict_types=1);

namespace Affilun\Application\Audit;

use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable per-tick outcome carried back from
 * {@see AuditRetentionPurger::purge()}.
 *
 * See the file-level docblock for the field semantics, the
 * non-negative `deleted` invariant, and the role this value
 * object plays at the worker-to-scheduler observability seam.
 */
final class AuditRetentionPurgeResult {

	/**
	 * UTC instant stamped at the top of the purge tick.
	 *
	 * Same value the {@see AuditRetentionPolicy::cutoffFrom()}
	 * arithmetic consumed to derive {@see self::$cutoff}, so a
	 * downstream observer can reconstruct the policy
	 * computation without re-reading the clock.
	 */
	private DateTimeImmutable $tickStartedAt;

	/**
	 * Strict-less-than UTC cutoff instant the persistence layer
	 * compared row `created_at` values against.
	 */
	private DateTimeImmutable $cutoff;

	/**
	 * Configured retention window in days at the moment of the
	 * sweep.
	 *
	 * Echoed back from the {@see AuditRetentionPolicy} the
	 * purger was holding when {@see AuditRetentionPurger::purge()}
	 * ran, so the audit row recording the purge can carry the
	 * policy state at the moment of the sweep even if the live
	 * policy is later reconfigured.
	 */
	private int $retentionDays;

	/**
	 * Number of rows the persistence layer reports as removed
	 * during this tick (`>= 0`).
	 */
	private int $deleted;

	/**
	 * Construct the result with explicit per-axis values.
	 *
	 * @param DateTimeImmutable $tickStartedAt UTC tick-start instant.
	 * @param DateTimeImmutable $cutoff        UTC strict-less-than cutoff.
	 * @param int               $retentionDays Retention window in days
	 *                                         (>= 1).
	 * @param int               $deleted       Number of rows removed
	 *                                         (>= 0).
	 *
	 * @throws InvalidArgumentException When `$retentionDays` is
	 *                                  below 1 or `$deleted` is
	 *                                  negative.
	 */
	public function __construct(
		DateTimeImmutable $tickStartedAt,
		DateTimeImmutable $cutoff,
		int $retentionDays,
		int $deleted
	) {
		if ( $retentionDays < 1 ) {
			$message = sprintf(
				'AuditRetentionPurgeResult: retentionDays must be >= 1, got %d.',
				$retentionDays
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		$utc                 = new DateTimeZone( 'UTC' );
		$this->tickStartedAt = $tickStartedAt->setTimezone( $utc );
		$this->cutoff        = $cutoff->setTimezone( $utc );
		$this->retentionDays = $retentionDays;
		$this->deleted       = $deleted;
	}

	/**
	 * UTC instant stamped at the top of the purge tick.
	 */
	public function tickStartedAt(): DateTimeImmutable {
		return $this->tickStartedAt;
	}

	/**
	 * Strict-less-than UTC cutoff instant.
	 */
	public function cutoff(): DateTimeImmutable {
		return $this->cutoff;
	}

	/**
	 * Configured retention window in days at the moment of the
	 * sweep.
	 */
	public function retentionDays(): int {
		return $this->retentionDays;
	}

	/**
	 * Number of rows removed during this tick (`>= 0`).
	 */
	public function deleted(): int {
		return $this->deleted;
	}

	/**
	 * Whether the tick removed at least one row.
	 *
	 * Convenience predicate so the surrounding scheduler /
	 * audit-emit path can elide "purge observed nothing" log
	 * lines on idle ticks without having to inspect
	 * {@see self::deleted()} directly.
	 */
	public function didDelete(): bool {
		return $this->deleted > 0;
	}
}
