<?php
/**
 * Cached entry value object.
 *
 * Common immutable carrier used by every Phase 9 cache layer
 * (`SearchCache`, `LookupCache`, `RenderCache`,
 * `ProviderHealthCache`) to wrap a stored value alongside its
 * temporal envelope. Centralizing the storedAt / expiresAt pair
 * here lets each cache port keep its public surface focused on
 * its own value type while the eviction rule
 * ({@see self::isExpired()} returning true at or after
 * `expiresAt`) is defined in exactly one place.
 *
 * Spec: `specifications/sync-and-cache-strategy.md` §4 (cache
 * layers) defines the four cache surfaces this entry backs.
 *
 * The entry itself is clock-agnostic: it does not know "now" and
 * does not auto-expire. Callers consult {@see self::isExpired()}
 * with a {@see \Affilun\Domain\Clock}-supplied
 * instant when deciding whether to surface the entry to a hit or
 * treat it as a miss. This keeps the value object pure and lets
 * the in-memory backend ({@see \Affilun\Infrastructure\Cache\InMemoryCacheStore})
 * remain the single seam where eviction policy is applied.
 *
 * @package Affilun\Application\Cache
 */

declare(strict_types=1);

namespace Affilun\Application\Cache;

use DateTimeImmutable;
use InvalidArgumentException;

/**
 * Immutable value object carrying a cached value plus its
 * stored / expires timestamps.
 *
 * See the file-level docblock for the eviction-boundary rule
 * (now >= expiresAt is expired) and for why the entry does not
 * carry its own clock.
 */
final class CachedEntry {

	/**
	 * Cached value. Opaque to this VO; each cache adapter narrows
	 * the runtime type at its own boundary.
	 *
	 * @var mixed
	 */
	private $value;

	/**
	 * Instant at which the entry was written.
	 */
	private DateTimeImmutable $storedAt;

	/**
	 * Instant at which the entry becomes expired (inclusive).
	 */
	private DateTimeImmutable $expiresAt;

	/**
	 * Construct an entry.
	 *
	 * @param mixed             $value     Cached value.
	 * @param DateTimeImmutable $storedAt  Write instant.
	 * @param DateTimeImmutable $expiresAt Expiry instant; must be
	 *                                     greater than or equal to
	 *                                     `$storedAt`.
	 *
	 * @throws InvalidArgumentException When `$expiresAt` precedes
	 *                                  `$storedAt`.
	 */
	public function __construct( $value, DateTimeImmutable $storedAt, DateTimeImmutable $expiresAt ) {
		if ( $expiresAt < $storedAt ) {
			throw new InvalidArgumentException(
				'CachedEntry: expiresAt must be greater than or equal to storedAt.'
			);
		}

		$this->value     = $value;
		$this->storedAt  = $storedAt;
		$this->expiresAt = $expiresAt;
	}

	/**
	 * Cached value, returned verbatim.
	 *
	 * @return mixed
	 */
	public function value() {
		return $this->value;
	}

	/**
	 * Instant at which the entry was written.
	 */
	public function storedAt(): DateTimeImmutable {
		return $this->storedAt;
	}

	/**
	 * Instant at which the entry becomes expired (inclusive).
	 */
	public function expiresAt(): DateTimeImmutable {
		return $this->expiresAt;
	}

	/**
	 * Report whether the entry is expired relative to the given
	 * instant.
	 *
	 * The boundary is inclusive: `$now == $expiresAt` is treated
	 * as expired so a TTL of N seconds yields exactly N seconds
	 * of liveness, with the Nth-second boundary crossing into
	 * the miss state.
	 *
	 * @param DateTimeImmutable $now Reference instant supplied by
	 *                               the caller's clock.
	 */
	public function isExpired( DateTimeImmutable $now ): bool {
		return $now >= $this->expiresAt;
	}
}
