<?php
/**
 * Transient-backed generic cache backend.
 *
 * Phase 9 MVP {@see \WP_Object_Cache} alternative that mirrors
 * {@see \CommerceLinkBuilder\Infrastructure\Cache\InMemoryCacheStore}'s
 * public surface so the four cache adapters
 * ({@see TransientsSearchCache}, {@see TransientsLookupCache},
 * {@see TransientsRenderCache},
 * {@see TransientsProviderHealthCache}) can swap in this
 * persistent backend without any per-adapter logic change.
 *
 * Spec: `specifications/sync-and-cache-strategy.md` §4.2 lists
 * "Object Cache / Transients" as the L2 cache surface. Phase 9
 * implements the Transients half; an Object-Cache wrapping
 * (cache groups + persistent-backend detection) is left as a
 * follow-up slice.
 *
 * Transient-name policy:
 *
 *   The cache keys produced by {@see \CommerceLinkBuilder\Application\Cache\CacheKeyFactory}
 *   are colon-separated tokens that can comfortably exceed the
 *   WordPress transient-name length limit (172 chars on the
 *   options table; 167 with the `_transient_timeout_` prefix on
 *   sitemeta on multisite). To stay safely within that limit
 *   while remaining deterministic across requests, every cache
 *   key is hashed to `'clb_' . substr( sha256( key ), 0, 40 )`
 *   (44 chars total). The original key is preserved inside the
 *   stored envelope for diagnostics.
 *
 * MVP limitations (intentional, documented):
 *
 *   - {@see self::flush()} performs a targeted DB scan over the
 *     layer's transient names when a `$wpdb` collaborator is
 *     supplied at construction time (Phase 2 KI-001 resolution).
 *     The scan deletes both `_transient_clb_<namespace>_%` and
 *     `_transient_timeout_clb_<namespace>_%` rows from
 *     `wp_options` so the layer's persisted transients and their
 *     timeout siblings are removed atomically. When `$wpdb` is
 *     null (pure-unit test path), {@see self::flush()} remains a
 *     no-op for backward compatibility — callers that need flush
 *     semantics in tests inject the {@see InMemoryCacheStore}
 *     family instead.
 *
 *   - {@see self::forgetByPrefix()} performs a $wpdb-backed scan
 *     over the layer's transient names when a `$wpdb` collaborator
 *     is supplied at construction time (Phase 2 KI-001 follow-up).
 *     The persisted transient name itself is a sha256 hash of the
 *     original cache key and therefore opaque to a prefix match,
 *     but the envelope written by {@see self::putRaw()} preserves
 *     the original (un-hashed) key under the {@see self::ENVELOPE_KEY}
 *     slot, so the scan can read each envelope back, inspect
 *     `envelope['key']`, and `delete_transient()` only the rows
 *     whose key starts with `$prefix`. When `$wpdb` is null
 *     (pure-unit test path), {@see self::forgetByPrefix()} remains
 *     a no-op returning 0 for backward compatibility — callers that
 *     need prefix-sweep semantics in tests inject the
 *     {@see InMemoryCacheStore} family instead.
 *
 * Layer namespace:
 *
 *   The store accepts an optional `$namespace` string at
 *   construction time so each cache layer (search, lookup,
 *   render, provider-health) gets its own physical option-name
 *   prefix (`clb_search_`, `clb_lookup_`, `clb_render_`,
 *   `clb_phealth_`). Per-layer namespacing keeps
 *   {@see self::flush()} narrow — a render-layer flush MUST NOT
 *   sweep search-layer transients — and keeps the diagnostics
 *   surface readable when an operator inspects `wp_options`
 *   directly.
 *
 * @package CommerceLinkBuilder\Infrastructure\Cache
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Cache;

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

/**
 * Transient-backed generic cache store with TTL-driven eviction.
 *
 * See the file-level docblock for the transient-name hashing
 * policy and the documented Phase-9-MVP no-op semantics for
 * {@see self::flush()} / {@see self::forgetByPrefix()}.
 */
final class TransientsCacheStore {

	/**
	 * Default name-prefix used when no `$namespace` is supplied.
	 *
	 * Kept for backward compatibility with the Phase 9 wiring that
	 * constructed every layer's store without a namespace; new
	 * call sites SHOULD supply an explicit namespace so
	 * {@see self::flush()} can scope its DB scan.
	 */
	public const DEFAULT_NAME_PREFIX = 'clb_';

	/**
	 * Envelope key carrying the original (un-hashed) cache key so
	 * diagnostics can recover the human-readable form.
	 */
	private const ENVELOPE_KEY = 'key';

	/**
	 * Envelope key carrying the cached payload.
	 */
	private const ENVELOPE_VALUE = 'value';

	/**
	 * Envelope key carrying the write instant (ISO-8601, UTC).
	 */
	private const ENVELOPE_STORED_AT = 'storedAt';

	/**
	 * Envelope key carrying the expiry instant (ISO-8601, UTC).
	 */
	private const ENVELOPE_EXPIRES_AT = 'expiresAt';

	/**
	 * Clock used to stamp `storedAt` / `expiresAt` and to apply
	 * read-time TTL eviction defensively (Transients are
	 * auto-expiring on the WordPress side, but the clock-based
	 * check keeps semantics aligned with the in-memory store).
	 */
	private Clock $clock;

	/**
	 * Callable matching `get_transient( string $name ): mixed`.
	 *
	 * @var callable(string):mixed
	 */
	private $getTransientFn;

	/**
	 * Callable matching `set_transient( string, mixed, int ): bool`.
	 *
	 * @var callable(string,mixed,int):bool
	 */
	private $setTransientFn;

	/**
	 * Callable matching `delete_transient( string ): bool`.
	 *
	 * @var callable(string):bool
	 */
	private $deleteTransientFn;

	/**
	 * Layer namespace component used in the transient name
	 * prefix. Empty string means "no namespace" (legacy MVP
	 * wiring); a non-empty namespace becomes part of the
	 * persisted option name so {@see self::flush()} can scope its
	 * DB scan to a single layer.
	 */
	private string $layerNamespace;

	/**
	 * Cached transient-name prefix derived from {@see self::$namespace}.
	 *
	 * Computed once at construction time so {@see self::transientName()}
	 * does not have to re-derive it per call.
	 */
	private string $namePrefix;

	/**
	 * Optional `$wpdb` collaborator used by {@see self::flush()}.
	 *
	 * Typed as `object` rather than the concrete `\wpdb` class so
	 * this file stays trivially loadable in unit tests that run
	 * outside a WordPress environment. When null, {@see self::flush()}
	 * is a no-op for backward compatibility with the Phase 9 wiring.
	 */
	private ?object $wpdb;

	/**
	 * Construct the store.
	 *
	 * The three callable parameters default to thin wrappers
	 * around the global `get_transient()`, `set_transient()`, and
	 * `delete_transient()` functions when WordPress is loaded.
	 * Tests inject stub callables that record arguments and
	 * return canned responses, exercising this class without a
	 * live WordPress.
	 *
	 * @param Clock                                $clock             Clock supplying "now".
	 * @param callable(string):mixed|null          $getTransientFn    Optional override for `get_transient`.
	 * @param callable(string,mixed,int):bool|null $setTransientFn    Optional override for `set_transient`.
	 * @param callable(string):bool|null           $deleteTransientFn Optional override for `delete_transient`.
	 * @param string                               $layerNamespace    Layer namespace component
	 *                                                                inserted into the transient
	 *                                                                name prefix; empty string
	 *                                                                preserves the legacy
	 *                                                                `clb_` prefix.
	 * @param object|null                          $wpdb              Optional `$wpdb` collaborator
	 *                                                                used by {@see self::flush()}
	 *                                                                to issue the targeted DB scan.
	 *                                                                When null, {@see self::flush()}
	 *                                                                remains a no-op.
	 *
	 * @throws InvalidArgumentException When `$layerNamespace` contains
	 *                                  characters outside the
	 *                                  `[a-z0-9_]` grammar that
	 *                                  the option-name prefix
	 *                                  expects.
	 */
	public function __construct(
		Clock $clock,
		?callable $getTransientFn = null,
		?callable $setTransientFn = null,
		?callable $deleteTransientFn = null,
		string $layerNamespace = '',
		?object $wpdb = null
	) {
		if ( $layerNamespace !== '' && preg_match( '/^[a-z0-9_]+$/', $layerNamespace ) !== 1 ) {
			$message = sprintf(
				'TransientsCacheStore: layerNamespace must match ^[a-z0-9_]+$; got "%s".',
				$layerNamespace
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		$this->clock             = $clock;
		$this->layerNamespace    = $layerNamespace;
		$this->namePrefix        = $layerNamespace === ''
			? self::DEFAULT_NAME_PREFIX
			: self::DEFAULT_NAME_PREFIX . $layerNamespace . '_';
		$this->wpdb              = $wpdb;
		$this->getTransientFn    = $getTransientFn ?? self::defaultGetTransient();
		$this->setTransientFn    = $setTransientFn ?? self::defaultSetTransient();
		$this->deleteTransientFn = $deleteTransientFn ?? self::defaultDeleteTransient();
	}

	/**
	 * Layer namespace component supplied at construction time, or
	 * the empty string when no namespace was supplied.
	 */
	public function layerNamespace(): string {
		return $this->layerNamespace;
	}

	/**
	 * Transient-name prefix this store writes under
	 * (e.g. `clb_render_` for the render layer).
	 *
	 * Exposed so diagnostics surfaces and tests can probe the
	 * exact prefix without re-deriving it.
	 */
	public function namePrefix(): string {
		return $this->namePrefix;
	}

	/**
	 * Read the entry stored under `$key`.
	 *
	 * Returns null on miss (the WordPress `get_transient()`
	 * convention is to return `false` on miss), on a malformed
	 * stored envelope, or once the entry's clock-based TTL has
	 * elapsed.
	 *
	 * @param string $key Cache key.
	 */
	public function getRaw( string $key ): ?CachedEntry {
		$name = $this->localTransientName( $key );
		/** @var mixed $raw */
		$raw = ( $this->getTransientFn )( $name );

		if ( false === $raw || ! is_array( $raw ) ) {
			return null;
		}

		if (
			! array_key_exists( self::ENVELOPE_VALUE, $raw )
			|| ! array_key_exists( self::ENVELOPE_STORED_AT, $raw )
			|| ! array_key_exists( self::ENVELOPE_EXPIRES_AT, $raw )
		) {
			return null;
		}

		$storedAtRaw  = $raw[ self::ENVELOPE_STORED_AT ];
		$expiresAtRaw = $raw[ self::ENVELOPE_EXPIRES_AT ];
		if ( ! is_string( $storedAtRaw ) || ! is_string( $expiresAtRaw ) ) {
			return null;
		}

		$storedAt  = self::parseInstant( $storedAtRaw );
		$expiresAt = self::parseInstant( $expiresAtRaw );
		if ( null === $storedAt || null === $expiresAt ) {
			return null;
		}

		$entry = new CachedEntry( $raw[ self::ENVELOPE_VALUE ], $storedAt, $expiresAt );

		if ( $entry->isExpired( $this->clock->now() ) ) {
			( $this->deleteTransientFn )( $name );
			return null;
		}

		return $entry;
	}

	/**
	 * Write the value under `$key` with the given TTL in seconds.
	 *
	 * The stored envelope carries the original key plus the
	 * `storedAt` / `expiresAt` timestamps so a subsequent
	 * {@see self::getRaw()} can reconstruct a {@see CachedEntry}
	 * without re-deriving the temporal boundary.
	 *
	 * @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.
				'TransientsCacheStore: ttlSeconds must be greater than zero; received ' . $ttlSeconds . '.'
			);
		}

		$storedAt  = $this->clock->now();
		$expiresAt = $storedAt->modify( '+' . $ttlSeconds . ' seconds' );
		if ( false === $expiresAt ) {
			throw new InvalidArgumentException(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				'TransientsCacheStore: failed to compute expiresAt from ttlSeconds=' . $ttlSeconds . '.'
			);
		}

		$envelope = array(
			self::ENVELOPE_KEY        => $key,
			self::ENVELOPE_VALUE      => $value,
			self::ENVELOPE_STORED_AT  => $storedAt->format( DateTimeImmutable::ATOM ),
			self::ENVELOPE_EXPIRES_AT => $expiresAt->format( DateTimeImmutable::ATOM ),
		);

		( $this->setTransientFn )( $this->localTransientName( $key ), $envelope, $ttlSeconds );
	}

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

	/**
	 * Remove every transient this store wrote, scoped to the
	 * layer's namespace.
	 *
	 * Behavior:
	 *
	 *   - When the store was constructed without a `$wpdb`
	 *     collaborator (typical pure-unit test path), this method
	 *     is a no-op for backward compatibility with the Phase 9
	 *     MVP wiring.
	 *
	 *   - When `$wpdb` is available, the method issues a single
	 *     `DELETE FROM {$wpdb->options} WHERE option_name LIKE
	 *     '_transient_<prefix>%' OR option_name LIKE
	 *     '_transient_timeout_<prefix>%'` query so both the
	 *     persisted transient and its timeout sibling are removed
	 *     atomically. The `<prefix>` is the
	 *     {@see self::namePrefix()} value, ensuring the sweep
	 *     never strays outside the layer's namespace.
	 *
	 *   - The method delegates the LIKE-escape to
	 *     `$wpdb->esc_like()` so a future namespace value that
	 *     contains a `_` character cannot accidentally widen the
	 *     match scope.
	 */
	public function flush(): void {
		if ( $this->wpdb === null ) {
			// No DB scan available — preserve legacy no-op behavior.
			return;
		}

		$wpdb = $this->wpdb;

		if ( ! property_exists( $wpdb, 'options' ) ) {
			return;
		}
		$optionsTable = (string) $wpdb->options;
		if ( $optionsTable === '' ) {
			return;
		}

		if (
			! method_exists( $wpdb, 'esc_like' )
			|| ! method_exists( $wpdb, 'prepare' )
			|| ! method_exists( $wpdb, 'query' )
		) {
			return;
		}

		$prefix               = $this->namePrefix;
		$escapedLike          = $wpdb->esc_like( $prefix ) . '%';
		$transientLike        = '_transient_' . $escapedLike;
		$transientTimeoutLike = '_transient_timeout_' . $escapedLike;

		$deleteSql = 'DELETE FROM ' . $optionsTable
			. ' WHERE option_name LIKE %s OR option_name LIKE %s';

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name resolved from $wpdb->options; values bound via prepare().
		$sql = $wpdb->prepare( $deleteSql, $transientLike, $transientTimeoutLike );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- KI-001 targeted transient sweep; SQL prepared above.
		$wpdb->query( $sql );
	}

	/**
	 * Drop every entry whose original (un-hashed) cache key begins
	 * with `$prefix`, returning the count of removed entries.
	 *
	 * Behavior:
	 *
	 *   - When the store was constructed without a `$wpdb`
	 *     collaborator (typical pure-unit test path), this method
	 *     is a no-op returning 0 for backward compatibility with
	 *     the Phase 9 MVP wiring.
	 *
	 *   - When `$wpdb` is available, the method issues a single
	 *     `SELECT option_name, option_value FROM {$wpdb->options}
	 *     WHERE option_name LIKE '_transient_<prefix>%'` query to
	 *     enumerate every transient row this layer wrote, decodes
	 *     each envelope, and `delete_transient()`s the rows whose
	 *     `envelope['key']` starts with `$prefix`. The persisted
	 *     transient name itself is a sha256 hash of the original
	 *     key and therefore opaque to the LIKE clause; the
	 *     envelope's preserved `key` slot is what the prefix
	 *     compare consults.
	 *
	 *   - Empty `$prefix` is rejected as a defensive guard so an
	 *     accidental layer-wide flush does not slip through this
	 *     surface; callers that want a layer-wide flush should
	 *     invoke {@see self::flush()} explicitly.
	 *
	 *   - The returned count reflects the number of envelopes
	 *     whose key matched the prefix, regardless of whether the
	 *     subsequent `delete_transient()` reported success: a
	 *     transient that was already auto-expired by WordPress
	 *     between the SELECT and the DELETE counts as removed
	 *     because it is no longer accessible from this layer.
	 *
	 * @param string $prefix Cache-key prefix; rejected when empty.
	 *
	 * @throws InvalidArgumentException When `$prefix` is empty.
	 */
	public function forgetByPrefix( string $prefix ): int {
		if ( $prefix === '' ) {
			throw new InvalidArgumentException(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				'TransientsCacheStore: forgetByPrefix() rejects empty prefix; use flush() for a layer-wide sweep.'
			);
		}

		if ( $this->wpdb === null ) {
			// No DB scan available — preserve legacy no-op behavior.
			return 0;
		}

		$wpdb = $this->wpdb;

		if ( ! property_exists( $wpdb, 'options' ) ) {
			return 0;
		}
		$optionsTable = (string) $wpdb->options;
		if ( $optionsTable === '' ) {
			return 0;
		}

		if (
			! method_exists( $wpdb, 'esc_like' )
			|| ! method_exists( $wpdb, 'prepare' )
			|| ! method_exists( $wpdb, 'get_results' )
		) {
			return 0;
		}

		$escapedNamespace = $wpdb->esc_like( $this->namePrefix ) . '%';
		$transientLike    = '_transient_' . $escapedNamespace;

		$selectSql = 'SELECT option_name, option_value FROM ' . $optionsTable
			. ' WHERE option_name LIKE %s';

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name resolved from $wpdb->options; values bound via prepare().
		$prepared = $wpdb->prepare( $selectSql, $transientLike );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- KI-001 prefix-sweep enumeration; rows consumed immediately and never cached.
		$rows = $wpdb->get_results( $prepared, 'ARRAY_A' );

		if ( ! is_array( $rows ) || $rows === array() ) {
			return 0;
		}

		$prefixLength = strlen( $this->namePrefix );
		$removed      = 0;

		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			if ( ! array_key_exists( 'option_name', $row ) || ! array_key_exists( 'option_value', $row ) ) {
				continue;
			}

			$optionName = (string) $row['option_name'];
			if ( strpos( $optionName, '_transient_' ) !== 0 ) {
				continue;
			}

			$rawValue = $row['option_value'];
			if ( ! is_string( $rawValue ) ) {
				continue;
			}

			// WordPress stores transient values serialized via
			// maybe_serialize() at the options-table boundary; the
			// SELECT returns the raw serialized payload, which we
			// must unserialize back into the envelope array. Object
			// instantiation is explicitly disabled.
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors.Discouraged -- Decoding WordPress-owned transient payload; object injection is blocked via allowed_classes=false.
			$envelope = @unserialize( $rawValue, array( 'allowed_classes' => false ) );
			if ( ! is_array( $envelope ) ) {
				continue;
			}

			if ( ! array_key_exists( self::ENVELOPE_KEY, $envelope ) ) {
				continue;
			}
			$originalKey = $envelope[ self::ENVELOPE_KEY ];
			if ( ! is_string( $originalKey ) ) {
				continue;
			}

			if ( strpos( $originalKey, $prefix ) !== 0 ) {
				continue;
			}

			// Strip the leading `_transient_` segment so the value
			// matches the canonical transient name expected by
			// delete_transient().
			$transientName = substr( $optionName, strlen( '_transient_' ) );
			if ( $transientName === '' ) {
				continue;
			}

			( $this->deleteTransientFn )( $transientName );
			++$removed;
		}

		return $removed;
	}



	/**
	 * Compute the deterministic transient name for `$key` under
	 * the supplied namespace.
	 *
	 * Returns `clb_<namespace>_<sha256-40>` when `$namespace` is
	 * non-empty, or `clb_<sha256-40>` (the legacy Phase 9 shape)
	 * when `$namespace` is the empty string. The total length
	 * stays well below the 172-char transient-name limit. The
	 * hash is stable across requests so a write from one request
	 * can be read from another.
	 *
	 * Kept static for backward compatibility with the Phase 9
	 * call sites and tests that expect a one-argument signature;
	 * the default empty `$namespace` preserves their behavior.
	 *
	 * @param string $key            Cache key.
	 * @param string $layerNamespace Optional layer namespace.
	 */
	public static function transientName( string $key, string $layerNamespace = '' ): string {
		$prefix = $layerNamespace === ''
			? self::DEFAULT_NAME_PREFIX
			: self::DEFAULT_NAME_PREFIX . $layerNamespace . '_';
		return $prefix . substr( hash( 'sha256', $key ), 0, 40 );
	}

	/**
	 * Instance-scoped helper that derives the transient name for
	 * `$key` using this store's pre-computed
	 * {@see self::$namePrefix}.
	 *
	 * Equivalent to {@see self::transientName()} with the store's
	 * namespace, but avoids re-deriving the prefix per call.
	 *
	 * @param string $key Cache key.
	 */
	private function localTransientName( string $key ): string {
		return $this->namePrefix . substr( hash( 'sha256', $key ), 0, 40 );
	}

	/**
	 * Parse an ISO-8601 timestamp produced by
	 * {@see DateTimeImmutable::ATOM}, returning null on failure.
	 *
	 * @param string $stamp ISO-8601 timestamp string.
	 */
	private static function parseInstant( string $stamp ): ?DateTimeImmutable {
		$parsed = DateTimeImmutable::createFromFormat( DateTimeImmutable::ATOM, $stamp );
		return $parsed instanceof DateTimeImmutable ? $parsed : null;
	}

	/**
	 * Default `get_transient` wrapper.
	 *
	 * @return callable(string):mixed
	 */
	private static function defaultGetTransient(): callable {
		return static function ( string $name ) {
			if ( ! function_exists( 'get_transient' ) ) {
				throw new LogicException(
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
					'TransientsCacheStore: get_transient() is not available; inject a callable override.'
				);
			}
			/** @var mixed $value */
			$value = get_transient( $name );
			return $value;
		};
	}

	/**
	 * Default `set_transient` wrapper.
	 *
	 * @return callable(string,mixed,int):bool
	 */
	private static function defaultSetTransient(): callable {
		return static function ( string $name, $value, int $ttl ): bool {
			if ( ! function_exists( 'set_transient' ) ) {
				throw new LogicException(
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
					'TransientsCacheStore: set_transient() is not available; inject a callable override.'
				);
			}
			return (bool) set_transient( $name, $value, $ttl );
		};
	}

	/**
	 * Default `delete_transient` wrapper.
	 *
	 * @return callable(string):bool
	 */
	private static function defaultDeleteTransient(): callable {
		return static function ( string $name ): bool {
			if ( ! function_exists( 'delete_transient' ) ) {
				throw new LogicException(
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
					'TransientsCacheStore: delete_transient() is not available; inject a callable override.'
				);
			}
			return (bool) delete_transient( $name );
		};
	}
}
