<?php
/**
 * Audit retention policy value object.
 *
 * Phase 2 (KI-006 follow-up) immutable VO that encodes the
 * `affilun_audit_events` retention threshold the §10.4 purge worker
 * applies on every sweep. The policy is intentionally narrow:
 * a single integer "retention window in days" plus the helper
 * arithmetic ({@see self::cutoffFrom()}) the worker needs to
 * convert "now" into the strict-less-than cutoff
 * {@see \Affilun\Infrastructure\Persistence\AuditEventRepository::deleteOlderThan()}
 * accepts.
 *
 * Default retention window:
 *
 *   - {@see self::DEFAULT_RETENTION_DAYS} = 365 days, sitting at
 *     the upper end of the spec's "180 日〜365 日以上" guidance
 *     in `specifications/database-schema.md` §10.4. The audit
 *     trail is the canonical evidence source for release gates,
 *     aftercare reviews, and incident post-mortems, so the
 *     default biases toward "keep more" rather than "purge
 *     aggressively". Operators that need a longer window
 *     (regulated industries, multi-year compliance review)
 *     supply a larger value via the constructor; operators that
 *     need a shorter window (storage pressure, narrowly-scoped
 *     deployments) supply a smaller one bounded below by
 *     {@see self::MINIMUM_RETENTION_DAYS}.
 *
 * Minimum retention window:
 *
 *   - {@see self::MINIMUM_RETENTION_DAYS} = 30 days. The §10.4
 *     guidance ("operational log と同じ条件で削除しない", "通常
 *     ログより長く保持する") forbids treating audit rows as
 *     short-lived operational telemetry; a 30-day floor makes
 *     "I accidentally configured retention to 1 day and lost a
 *     month of evidence" a constructor-time failure rather than
 *     a silent post-purge regret. Operators that genuinely want
 *     a sub-30-day window are deliberately steered toward
 *     extending the floor itself rather than smuggling a smaller
 *     value through this VO.
 *
 * Cutoff arithmetic:
 *
 *   - {@see self::cutoffFrom()} accepts the worker's stamped
 *     "now" instant and returns `now - retentionDays`. The
 *     returned cutoff is normalized to UTC even when the input
 *     is not, so a non-UTC clock cannot smuggle in an off-by-one
 *     drift relative to the column's UTC storage convention.
 *
 *   - The cutoff is strict-less-than per the
 *     {@see \Affilun\Infrastructure\Persistence\AuditEventRepository::deleteOlderThan()}
 *     contract: a row whose `created_at` exactly equals the
 *     cutoff survives at least one full sweep. This VO does not
 *     itself perform the comparison; it only supplies the
 *     cutoff value the persistence layer compares against.
 *
 * Immutable: there is no `withRetentionDays()` mutation
 * surface. A policy is constructed once at the worker
 * composition seam (the cron service provider) and held by
 * reference for every subsequent tick.
 *
 * @package Affilun\Application\Audit
 */

declare(strict_types=1);

namespace Affilun\Application\Audit;

use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable retention policy for the §10.4 audit-events purge
 * worker.
 *
 * See the file-level docblock for the default / minimum
 * retention windows, the strict-less-than cutoff contract, and
 * the rationale for biasing the default toward "keep more"
 * rather than "purge aggressively".
 */
final class AuditRetentionPolicy {

	/**
	 * Default retention window in days, sitting at the upper end
	 * of the spec §10.4 "180 日〜365 日以上" guidance.
	 *
	 * Held as a class constant so the cron service provider, the
	 * diagnostics surface, and the test suite share one source
	 * of truth for the production default.
	 */
	public const DEFAULT_RETENTION_DAYS = 365;

	/**
	 * Minimum retention window in days. Constructor calls with a
	 * smaller value raise {@see InvalidArgumentException} so a
	 * misconfiguration surfaces at composition time rather than
	 * post-purge.
	 *
	 * See the file-level docblock for the rationale: the §10.4
	 * guidance forbids treating audit rows as short-lived
	 * operational telemetry, and a 30-day floor is a defensible
	 * lower bound for "we kept enough evidence to investigate
	 * the most recent monthly release cycle".
	 */
	public const MINIMUM_RETENTION_DAYS = 30;

	/**
	 * Configured retention window in days; rows whose
	 * `created_at` is strictly older than `now - retentionDays`
	 * are purged on the next sweep.
	 */
	private int $retentionDays;

	/**
	 * Construct the policy with an explicit retention window in
	 * days.
	 *
	 * @param int $retentionDays Retention window in days
	 *                           (>= {@see self::MINIMUM_RETENTION_DAYS}).
	 *                           Defaults to
	 *                           {@see self::DEFAULT_RETENTION_DAYS}.
	 *
	 * @throws InvalidArgumentException When `$retentionDays` is
	 *                                  below
	 *                                  {@see self::MINIMUM_RETENTION_DAYS}.
	 */
	public function __construct( int $retentionDays = self::DEFAULT_RETENTION_DAYS ) {
		if ( $retentionDays < self::MINIMUM_RETENTION_DAYS ) {
			$message = sprintf(
				'AuditRetentionPolicy: retentionDays must be >= %d (spec §10.4 forbids treating'
					. ' audit rows as short-lived operational telemetry); got %d.',
				self::MINIMUM_RETENTION_DAYS,
				$retentionDays
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		$this->retentionDays = $retentionDays;
	}

	/**
	 * Convenience factory producing a policy at the
	 * spec-recommended 365-day default.
	 *
	 * Exposed so the cron service provider's composition seam
	 * can stay readable (`AuditRetentionPolicy::default()`)
	 * without spelling out the constructor argument.
	 */
	public static function default(): self {
		return new self( self::DEFAULT_RETENTION_DAYS );
	}

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

	/**
	 * Compute the strict-less-than cutoff instant from the
	 * supplied "now" reference.
	 *
	 * Returns `$now - $retentionDays` normalized to UTC. The
	 * persistence layer compares row `created_at` instants
	 * strictly against the returned value — rows whose
	 * `created_at` is strictly less than the cutoff are removed,
	 * rows whose `created_at` equals the cutoff are preserved
	 * (so a row landing on the boundary survives at least one
	 * full sweep before disappearing on the next tick).
	 *
	 * Normalization to UTC is unconditional: even when `$now` is
	 * already in UTC the returned value is a fresh
	 * {@see DateTimeImmutable} so callers cannot accidentally
	 * mutate the worker's stamped "now" through a shared
	 * reference.
	 *
	 * @param DateTimeImmutable $now Worker-stamped reference
	 *                               instant; typically
	 *                               {@see \Affilun\Domain\Clock::now()}
	 *                               at the top of the purge
	 *                               tick.
	 */
	public function cutoffFrom( DateTimeImmutable $now ): DateTimeImmutable {
		$normalized = $now->setTimezone( new DateTimeZone( 'UTC' ) );

		return $normalized->sub( new DateInterval( 'P' . $this->retentionDays . 'D' ) );
	}
}
