<?php
/**
 * In-memory generic cache backend.
 *
 * Single-process associative-array store used by the four
 * Phase 9 in-memory cache adapters
 * ({@see InMemorySearchCache}, {@see InMemoryLookupCache},
 * {@see InMemoryRenderCache}, {@see InMemoryProviderHealthCache})
 * to share a common put / get / expire / flush implementation
 * without duplicating eviction logic per layer. Implementations
 * of the cache ports declared under
 * `src/Application/Cache/` compose an instance of this store
 * and add type-narrow helpers around {@see self::getRaw()} /
 * {@see self::putRaw()}.
 *
 * Spec: `specifications/sync-and-cache-strategy.md` §21.2 places
 * the in-memory cache backend under `Infrastructure/Cache/`,
 * mirroring the {@see \CommerceLinkBuilder\Infrastructure\Persistence\InMemoryOptionStore}
 * pattern that backs the §13 settings repository.
 *
 * Eviction model:
 *
 *   - Each entry carries the {@see \CommerceLinkBuilder\Application\Cache\CachedEntry}
 *     storedAt / expiresAt envelope.
 *   - {@see self::getRaw()} consults the injected
 *     {@see \CommerceLinkBuilder\Domain\Clock} for "now" and
 *     evicts an entry that is expired before returning null.
 *     Eviction is eager so a long-lived store does not pile up
 *     dead entries between explicit flush calls.
 *   - {@see self::putRaw()} rejects a non-positive TTL because
 *     the cache ports treat it as a programming error (see
 *     each port's `put()` contract).
 *
 * Storage scope is per-instance; callers that want strict
 * cross-layer isolation construct one store per layer (the
 * default wiring chosen by Phase 9), while callers that want
 * to share a single backend across layers may inject the same
 * instance into multiple adapters at their own risk.
 *
 * @package CommerceLinkBuilder\Infrastructure\Cache
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Cache;

use CommerceLinkBuilder\Application\Cache\CachedEntry;
use CommerceLinkBuilder\Domain\Clock;
use InvalidArgumentException;

/**
 * Array-backed generic cache store with TTL-driven eviction.
 *
 * See the file-level docblock for the eviction model, the
 * single-instance scope, and the relationship to the four
 * Phase 9 in-memory cache adapters that compose this class.
 */
final class InMemoryCacheStore {

	/**
	 * Stored entries keyed by cache key.
	 *
	 * @var array<string, CachedEntry>
	 */
	private array $entries = array();

	/**
	 * Clock used to apply TTL eviction at read time and to stamp
	 * `storedAt` / `expiresAt` at write time.
	 */
	private Clock $clock;

	/**
	 * Construct the store.
	 *
	 * @param Clock $clock Clock supplying "now" for read-time
	 *                     eviction and write-time stamping.
	 */
	public function __construct( Clock $clock ) {
		$this->clock = $clock;
	}

	/**
	 * Read the entry stored under `$key`.
	 *
	 * Returns null when the key is unset or when the entry is
	 * expired relative to the clock's current instant. Expired
	 * entries are removed eagerly so a long-lived store does not
	 * accumulate dead entries between explicit flush calls.
	 *
	 * @param string $key Cache key.
	 */
	public function getRaw( string $key ): ?CachedEntry {
		if ( ! array_key_exists( $key, $this->entries ) ) {
			return null;
		}

		$entry = $this->entries[ $key ];

		if ( $entry->isExpired( $this->clock->now() ) ) {
			unset( $this->entries[ $key ] );
			return null;
		}

		return $entry;
	}

	/**
	 * Write the value under `$key` with the given TTL in seconds.
	 *
	 * The entry's `storedAt` is stamped from the clock and its
	 * `expiresAt` is `storedAt + ttlSeconds`. A non-positive TTL
	 * is rejected as a programming error per each cache port's
	 * `put()` contract.
	 *
	 * @param string $key        Cache key.
	 * @param mixed  $value      Cached value (opaque to the store).
	 * @param int    $ttlSeconds TTL in seconds; must be > 0.
	 *
	 * @throws InvalidArgumentException When `$ttlSeconds` is non-positive.
	 */
	public function putRaw( string $key, $value, int $ttlSeconds ): void {
		if ( $ttlSeconds <= 0 ) {
			throw new InvalidArgumentException(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				'InMemoryCacheStore: ttlSeconds must be greater than zero; received ' . $ttlSeconds . '.'
			);
		}

		$storedAt  = $this->clock->now();
		$expiresAt = $storedAt->modify( '+' . $ttlSeconds . ' seconds' );

		// `\DateTimeImmutable::modify()` returns false on failure;
		// a positive integer offset cannot fail in practice but we
		// guard explicitly so the static analyzer can prove the
		// `\DateTimeImmutable` type that CachedEntry requires.
		if ( false === $expiresAt ) {
			throw new InvalidArgumentException(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				'InMemoryCacheStore: failed to compute expiresAt from ttlSeconds=' . $ttlSeconds . '.'
			);
		}

		$this->entries[ $key ] = new CachedEntry( $value, $storedAt, $expiresAt );
	}

	/**
	 * Remove the entry stored under `$key`. Idempotent.
	 *
	 * @param string $key Cache key.
	 */
	public function forget( string $key ): void {
		unset( $this->entries[ $key ] );
	}

	/**
	 * Drop every stored entry.
	 */
	public function flush(): void {
		$this->entries = array();
	}

	/**
	 * Drop every entry whose key begins with `$prefix` and return
	 * the count of removed entries.
	 *
	 * Rejects an empty prefix as a defensive guard against
	 * accidental layer-wide flushes; callers that genuinely want
	 * a layer-wide flush call {@see self::flush()} explicitly.
	 *
	 * @param string $prefix Cache-key prefix.
	 *
	 * @throws InvalidArgumentException When `$prefix` is empty.
	 */
	public function forgetByPrefix( string $prefix ): int {
		if ( '' === $prefix ) {
			throw new InvalidArgumentException(
				// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- Developer-facing message.
				'InMemoryCacheStore: forgetByPrefix() requires a non-empty prefix; call flush() to drop every entry.'
			);
		}

		$removed = 0;
		foreach ( array_keys( $this->entries ) as $key ) {
			if ( str_starts_with( $key, $prefix ) ) {
				unset( $this->entries[ $key ] );
				++$removed;
			}
		}

		return $removed;
	}

	/**
	 * Count of currently stored entries.
	 *
	 * Exposed as a testing helper; production code MUST NOT rely
	 * on this surface for cache-policy decisions.
	 */
	public function count(): int {
		return count( $this->entries );
	}
}
