<?php
/**
 * Cache service provider.
 *
 * Phase 9 slice 9h Bootstrap-layer service provider that wires
 * the §4 cache layers and the §14 cache invalidator into the
 * service-provider registry. The Phase 9 MVP wires the
 * Transients-backed adapters
 * ({@see \CommerceLinkBuilder\Infrastructure\Cache\TransientsSearchCache},
 * {@see \CommerceLinkBuilder\Infrastructure\Cache\TransientsLookupCache},
 * {@see \CommerceLinkBuilder\Infrastructure\Cache\TransientsRenderCache},
 * {@see \CommerceLinkBuilder\Infrastructure\Cache\TransientsProviderHealthCache})
 * as the production default; the in-memory adapters under
 * `Infrastructure/Cache/InMemory*` remain available for tests
 * via the constructor overrides.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_CACHE_KEY_FACTORY}      — §6.3 key factory.
 *   - {@see self::BINDING_SEARCH_CACHE}           — §4 search layer.
 *   - {@see self::BINDING_LOOKUP_CACHE}           — §4 lookup layer.
 *   - {@see self::BINDING_RENDER_CACHE}           — §4 render layer.
 *   - {@see self::BINDING_PROVIDER_HEALTH_CACHE}  — §4 provider-health layer.
 *   - {@see self::BINDING_CACHE_INVALIDATOR}      — §14 invalidator.
 *
 * Provider ordering — register() resolves a {@see Clock} binding
 * published by {@see PersistenceServiceProvider}, so the
 * bootstrap pipeline in `commerce-link-builder.php` MUST register
 * {@see PersistenceServiceProvider} before this provider.
 *
 * Layer isolation: a dedicated {@see TransientsCacheStore} is
 * constructed per cache adapter for symmetry with the in-memory
 * wiring even though the transient backend has no per-layer
 * flush state to partition. Sharing a single store across layers
 * would not currently change behavior, but keeping the wiring
 * one-to-one keeps the seam clean for the future Object-Cache
 * follow-up that introduces real per-layer cache groups.
 *
 * Resolution policy:
 *
 *   - Every collaborator is optional; omitting one causes the
 *     provider to construct the production default during
 *     register(). Tests inject overrides one axis at a time
 *     without standing up the persistence stack.
 *
 *   - Resolution is eager at register() time so a misconfigured
 *     bootstrap pipeline (Persistence provider missing) surfaces
 *     during {@see ServiceProviderRegistry::registerAll()} rather
 *     than at the first cache miss.
 *
 * @package CommerceLinkBuilder\Application\Cache
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Cache;

use CommerceLinkBuilder\Bootstrap\HookRegistrar;
use CommerceLinkBuilder\Bootstrap\ServiceProvider;
use CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry;
use CommerceLinkBuilder\Domain\Clock;
use CommerceLinkBuilder\Infrastructure\Cache\TransientsCacheStore;
use CommerceLinkBuilder\Infrastructure\Cache\TransientsLookupCache;
use CommerceLinkBuilder\Infrastructure\Cache\TransientsProviderHealthCache;
use CommerceLinkBuilder\Infrastructure\Persistence\EmbedItemRepository;
use CommerceLinkBuilder\Infrastructure\Cache\TransientsRenderCache;
use CommerceLinkBuilder\Infrastructure\Cache\TransientsSearchCache;
use CommerceLinkBuilder\Infrastructure\Clock\SystemClock;
use CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider;
use LogicException;

/**
 * Binds the §6.3 key factory, the four §4 cache layers, and the
 * §14 invalidator into the service-provider registry.
 *
 * See the file-level docblock for the bootstrap-pipeline ordering
 * requirement, the per-layer {@see TransientsCacheStore}
 * convention, and the resolution policy.
 */
final class CacheServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the §6.3 {@see CacheKeyFactory}
	 * is bound.
	 */
	public const BINDING_CACHE_KEY_FACTORY = 'clb.cache.key_factory';

	/**
	 * Registry key under which the §4 {@see SearchCache} is bound.
	 */
	public const BINDING_SEARCH_CACHE = 'clb.cache.search';

	/**
	 * Registry key under which the §4 {@see LookupCache} is bound.
	 */
	public const BINDING_LOOKUP_CACHE = 'clb.cache.lookup';

	/**
	 * Registry key under which the §4 {@see RenderCache} is bound.
	 */
	public const BINDING_RENDER_CACHE = 'clb.cache.render';

	/**
	 * Registry key under which the §4 {@see ProviderHealthCache}
	 * is bound.
	 */
	public const BINDING_PROVIDER_HEALTH_CACHE = 'clb.cache.provider_health';

	/**
	 * Registry key under which the §14 {@see CacheInvalidator}
	 * is bound.
	 */
	public const BINDING_CACHE_INVALIDATOR = 'clb.cache.invalidator';

	/**
	 * Persistence-provider {@see Clock} binding key.
	 *
	 * Held as a class constant so a future rename of the binding
	 * key on {@see \CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider}
	 * has a single point of change here. Phase 9's persistence
	 * provider does not currently publish a Clock binding, so
	 * register() falls back to a default-constructed
	 * {@see SystemClock} when the binding is absent. Once a Clock
	 * binding is published this provider will resolve it
	 * automatically without further wiring changes.
	 */
	private const PERSISTENCE_CLOCK_BINDING = 'persistence.clock';

	/**
	 * §6.3 cache key factory override.
	 */
	private ?CacheKeyFactory $keyFactoryOverride;

	/**
	 * §4 search-layer override.
	 */
	private ?SearchCache $searchCacheOverride;

	/**
	 * §4 lookup-layer override.
	 */
	private ?LookupCache $lookupCacheOverride;

	/**
	 * §4 render-layer override.
	 */
	private ?RenderCache $renderCacheOverride;

	/**
	 * §4 provider-health-layer override.
	 */
	private ?ProviderHealthCache $providerHealthCacheOverride;

	/**
	 * §14 invalidator override.
	 */
	private ?CacheInvalidator $invalidatorOverride;

	/**
	 * Optional `$wpdb` collaborator forwarded to every
	 * {@see TransientsCacheStore} this provider constructs so the
	 * Phase 2 KI-001 {@see TransientsCacheStore::flush()} DB scan
	 * has something to query. Null preserves the Phase 9 MVP
	 * behavior (flush is a no-op).
	 *
	 * Typed as `object` rather than the concrete `\wpdb` class so
	 * this file stays trivially loadable in unit tests that run
	 * outside a WordPress environment.
	 */
	private ?object $wpdb;

	/**
	 * Construct a cache service provider with optional overrides
	 * for each collaborator.
	 *
	 * Empty / no-arg construction is the production default; tests
	 * pass overrides one axis at a time so the cache wiring can be
	 * exercised against synthetic adapters without standing up the
	 * persistence stack.
	 *
	 * @param CacheKeyFactory|null     $keyFactoryOverride          §6.3 key factory override.
	 * @param SearchCache|null         $searchCacheOverride         §4 search-layer override.
	 * @param LookupCache|null         $lookupCacheOverride         §4 lookup-layer override.
	 * @param RenderCache|null         $renderCacheOverride         §4 render-layer override.
	 * @param ProviderHealthCache|null $providerHealthCacheOverride §4 provider-health-layer override.
	 * @param CacheInvalidator|null    $invalidatorOverride         §14 invalidator override.
	 * @param object|null              $wpdb                        Optional `$wpdb`
	 *                                                              collaborator forwarded to
	 *                                                              every TransientsCacheStore
	 *                                                              for KI-001 flush().
	 */
	public function __construct(
		?CacheKeyFactory $keyFactoryOverride = null,
		?SearchCache $searchCacheOverride = null,
		?LookupCache $lookupCacheOverride = null,
		?RenderCache $renderCacheOverride = null,
		?ProviderHealthCache $providerHealthCacheOverride = null,
		?CacheInvalidator $invalidatorOverride = null,
		?object $wpdb = null
	) {
		$this->keyFactoryOverride          = $keyFactoryOverride;
		$this->searchCacheOverride         = $searchCacheOverride;
		$this->lookupCacheOverride         = $lookupCacheOverride;
		$this->renderCacheOverride         = $renderCacheOverride;
		$this->providerHealthCacheOverride = $providerHealthCacheOverride;
		$this->invalidatorOverride         = $invalidatorOverride;
		$this->wpdb                        = $wpdb;
	}

	/**
	 * Bind the §6.3 key factory, the four §4 cache layers, and
	 * the §14 invalidator into the registry.
	 *
	 * Resolution order:
	 *
	 *   1. Resolve a {@see Clock} from the registry under
	 *      {@see self::PERSISTENCE_CLOCK_BINDING} when published;
	 *      otherwise default-construct {@see SystemClock}.
	 *
	 *   2. Build (or accept the override for) each cache adapter
	 *      with its own dedicated {@see TransientsCacheStore}.
	 *
	 *   3. Build (or accept the override for) the
	 *      {@see CacheInvalidator}, wiring the four cache layers
	 *      and the key factory.
	 *
	 *   4. Bind every collaborator under the corresponding
	 *      `BINDING_*` key.
	 *
	 * @param ServiceProviderRegistry $registry Registry driving
	 *                                          this bootstrap pass.
	 *
	 * @throws LogicException When the persistence provider has
	 *                        published a Clock binding that does
	 *                        not implement {@see Clock}.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		$this->requirePersistenceProvider( $registry );

		$clock = $this->resolveClock( $registry );

		$keyFactory = $this->keyFactoryOverride ?? new CacheKeyFactory();

		$searchCache = $this->searchCacheOverride
			?? new TransientsSearchCache(
				new TransientsCacheStore( $clock, null, null, null, 'search', $this->wpdb )
			);

		$lookupCache = $this->lookupCacheOverride
			?? new TransientsLookupCache(
				new TransientsCacheStore( $clock, null, null, null, 'lookup', $this->wpdb )
			);

		$renderCache = $this->renderCacheOverride
			?? new TransientsRenderCache(
				new TransientsCacheStore( $clock, null, null, null, 'render', $this->wpdb )
			);

		$providerHealthCache = $this->providerHealthCacheOverride
			?? new TransientsProviderHealthCache(
				new TransientsCacheStore( $clock, null, null, null, 'phealth', $this->wpdb )
			);

		$invalidator = $this->invalidatorOverride
			?? new CacheInvalidator(
				$searchCache,
				$lookupCache,
				$renderCache,
				$providerHealthCache,
				$keyFactory,
				$this->resolveEmbedItemRepository( $registry )
			);

		$registry->bind( self::BINDING_CACHE_KEY_FACTORY, $keyFactory );
		$registry->bind( self::BINDING_SEARCH_CACHE, $searchCache );
		$registry->bind( self::BINDING_LOOKUP_CACHE, $lookupCache );
		$registry->bind( self::BINDING_RENDER_CACHE, $renderCache );
		$registry->bind( self::BINDING_PROVIDER_HEALTH_CACHE, $providerHealthCache );
		$registry->bind( self::BINDING_CACHE_INVALIDATOR, $invalidator );
	}

	/**
	 * No-op — no WordPress hooks are registered by this provider.
	 *
	 * Cache adapters are passive collaborators consumed by other
	 * providers (REST controllers, sync handlers, render
	 * pipeline) at request-handling time; there is no
	 * `cache_init`-shaped hook to subscribe to.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		unset( $hooks );
	}

	/**
	 * Resolve the optional {@see EmbedItemRepository} binding for
	 * the §14.2 KI-002 per-product render-cache sweep, returning
	 * null when the persistence provider has not (yet) published
	 * the binding so the invalidator falls back on the Phase 9
	 * broad render-cache flush.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveEmbedItemRepository( ServiceProviderRegistry $registry ): ?EmbedItemRepository {
		$key = PersistenceServiceProvider::BINDING_EMBED_ITEM_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$value = $registry->get( $key );
		return $value instanceof EmbedItemRepository ? $value : null;
	}

	/**
	 * Verify that {@see PersistenceServiceProvider} has registered
	 * before this provider.
	 *
	 * Phase 9's persistence provider does not currently publish a
	 * dedicated Clock binding, but it always publishes the
	 * §13 option-store binding
	 * ({@see PersistenceServiceProvider::BINDING_OPTION_STORE}).
	 * That binding is used here as a sentinel so a misordered
	 * bootstrap pipeline (CacheServiceProvider registered before
	 * PersistenceServiceProvider) fails loudly during
	 * {@see ServiceProviderRegistry::registerAll()} rather than at
	 * the first cache miss.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the persistence binding is missing.
	 */
	private function requirePersistenceProvider( ServiceProviderRegistry $registry ): void {
		if ( ! $registry->has( PersistenceServiceProvider::BINDING_OPTION_STORE ) ) {
			$message = 'CacheServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing option-store sentinel binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}
	}

	/**
	 * Resolve a {@see Clock} from the registry, falling back to
	 * a default-constructed {@see SystemClock} when no binding is
	 * published.
	 *
	 * Phase 9's {@see PersistenceServiceProvider} does not
	 * currently publish a Clock binding, so the fallback is the
	 * production default. Once that binding is published this
	 * method picks it up automatically without further wiring
	 * changes.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When a binding exists but is not a
	 *                        {@see Clock}.
	 */
	private function resolveClock( ServiceProviderRegistry $registry ): Clock {
		if ( ! $registry->has( self::PERSISTENCE_CLOCK_BINDING ) ) {
			return new SystemClock();
		}

		$clock = $registry->get( self::PERSISTENCE_CLOCK_BINDING );
		if ( ! $clock instanceof Clock ) {
			$message = sprintf(
				'CacheServiceProvider expected Clock under "%s"; got %s.',
				self::PERSISTENCE_CLOCK_BINDING,
				is_object( $clock ) ? get_class( $clock ) : gettype( $clock )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $clock;
	}
}
