<?php
/**
 * Sync cron service provider.
 *
 * Phase 2 (post-MVP) Bootstrap-layer service provider that wires the
 * §11 / ADR-0021 §8 worker loop and the §8.3 periodic sweep to
 * WP-Cron. Owns the activation-time scheduling, the deactivation-time
 * unscheduling, the custom cron interval registration, and the
 * runtime action handlers that drive
 * {@see SyncWorker::tick()} and
 * {@see PeriodicSyncScheduler::tick()} once per scheduled tick.
 *
 * Cron event surface:
 *
 *   - `affilun_cron_dispatch_jobs` — fires every minute (`affilun_every_minute`
 *     custom interval). Drains up to {@see SyncWorker::DEFAULT_BATCH_SIZE}
 *     pending / reclaimable jobs from `affilun_sync_jobs` per tick.
 *
 *   - `affilun_cron_periodic_sync` — fires every five minutes
 *     (`affilun_every_five_minutes` custom interval). Sweeps the §13 stale
 *     cohort and enqueues `sync_product` jobs at the priority the
 *     {@see SyncPolicy} attributes to each record.
 *
 * Worker identity:
 *
 *   - The `locked_by` column on `affilun_sync_jobs` carries a stable
 *     per-site worker identifier so reclamation can attribute
 *     orphaned locks back to the worker that took them. The
 *     identifier is generated once at activation time, stored under
 *     the `affilun_sync_worker_id` option, and re-used for every
 *     subsequent tick. Sites with multiple PHP processes share the
 *     identifier — §8.5 reclamation only cares about lock age, not
 *     about which exact process held the lock.
 *
 * Activation / deactivation:
 *
 *   - On `affilun_activate` (fired by {@see Plugin::onActivate()} after
 *     the environment check passes), the provider:
 *
 *       1. Generates a fresh worker id under `affilun_sync_worker_id`
 *          when none exists.
 *
 *       2. Schedules `affilun_cron_dispatch_jobs` on the
 *          `affilun_every_minute` interval if not already scheduled.
 *
 *       3. Schedules `affilun_cron_periodic_sync` on the
 *          `affilun_every_five_minutes` interval if not already
 *          scheduled.
 *
 *   - On plugin deactivation (driven by
 *     {@see Plugin::onDeactivate()} once the deactivation hook
 *     subscriber lands), both events are unscheduled. The worker id
 *     option is INTENTIONALLY preserved so reactivation reuses the
 *     same identifier and any in-flight reclamation logic can
 *     correlate locks taken before the deactivation.
 *
 * Failure semantics:
 *
 *   - The cron action handlers catch every {@see \Throwable} the
 *     worker / scheduler raises and surface it through
 *     `error_log()`. WP-Cron itself will retry the action on the
 *     next tick — there is no value in re-throwing because that
 *     would only crash the cron worker process, not the underlying
 *     job. The §8.5 lock-timeout reclamation path picks up any
 *     in-flight job that was abandoned by a crashed handler.
 *
 *   - Resolution failures during {@see ServiceProvider::register()}
 *     (missing repository binding, missing provider registry) raise
 *     {@see LogicException} so a misordered bootstrap fails loudly
 *     here rather than at the first scheduled tick.
 *
 * @package Affilun\Bootstrap
 */

declare(strict_types=1);

namespace Affilun\Bootstrap;

use Affilun\Application\Audit\AuditEventWriter;
use Affilun\Application\Audit\AuditRetentionPolicy;
use Affilun\Application\Audit\AuditRetentionPurger;
use Affilun\Application\Audit\RepositoryAuditEventWriter;
use Affilun\Application\Analytics\ClickRetentionPolicy;
use Affilun\Application\Analytics\ClickRetentionPurger;
use Affilun\Application\Cache\CacheInvalidator;
use Affilun\Application\Cache\CacheServiceProvider;
use Affilun\Application\Product\CloudProductSearchKeywordsExtractor;
use Affilun\Application\Product\FallbackProductSearchKeywordsExtractor;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Settings\OptionStoreSettingsRepository;
use Affilun\Application\Sync\PeriodicSyncScheduler;
use Affilun\Application\Sync\RegistryBackedProviderResolver;
use Affilun\Application\Sync\StaleProductFinder;
use Affilun\Application\Sync\SyncDispatcher;
use Affilun\Application\Sync\SyncPolicy;
use Affilun\Application\Sync\SyncProductJobHandler;
use Affilun\Application\Sync\SyncWorker;
use Affilun\Application\Product\UseCase\SaveProductUseCase;
use Affilun\Domain\Clock;
use Affilun\Domain\Sync\RetryPolicy;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Persistence\AuditEventRepository;
use Affilun\Infrastructure\Persistence\ClickEventRepository;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\SyncJobRepository;
use Affilun\Presentation\Rest\ProviderServiceProvider;
use LogicException;
use Throwable;

/**
 * Wires the Phase 2 sync worker + periodic sweep to WP-Cron.
 *
 * See the file-level docblock for the cron event surface, the
 * worker-id option lifecycle, the activation / deactivation flow,
 * and the failure-promotion policy at the cron action handler
 * boundary.
 */
final class SyncCronServiceProvider implements ServiceProvider {

	/**
	 * WP option key under which the per-site worker id is stored.
	 *
	 * Held as a class constant so tests and diagnostics surfaces can
	 * reference the exact key without duplicating the literal.
	 */
	public const OPTION_WORKER_ID = 'affilun_sync_worker_id';

	/**
	 * WP-Cron action name for the worker-tick event.
	 */
	public const HOOK_DISPATCH_JOBS = 'affilun_cron_dispatch_jobs';

	/**
	 * WP-Cron action name for the periodic-sweep event.
	 */
	public const HOOK_PERIODIC_SYNC = 'affilun_cron_periodic_sync';

	/**
	 * Custom cron interval key registered for the worker tick.
	 */
	public const INTERVAL_EVERY_MINUTE = 'affilun_every_minute';

	/**
	 * Custom cron interval key registered for the periodic sweep.
	 */
	public const INTERVAL_EVERY_FIVE_MINUTES = 'affilun_every_five_minutes';

	/**
	 * WP-Cron action name for the §10.4 audit-events retention
	 * purge event (Phase 2 KI-006 follow-up).
	 */
	public const HOOK_AUDIT_RETENTION_PURGE = 'affilun_cron_audit_retention_purge';

	/**
	 * Custom cron interval key registered for the §10.4 audit
	 * retention purge sweep.
	 *
	 * Daily cadence per the §10.4 retention guidance: rows are
	 * measured in months so a per-minute or per-five-minute sweep
	 * would be wasteful, but a longer-than-daily cadence would
	 * leave the audit trail accumulating well past the retention
	 * window between sweeps.
	 */
	public const INTERVAL_DAILY_AUDIT_PURGE = 'affilun_audit_retention_daily';

	/**
	 * WP-Cron action name for click analytics retention purge.
	 */
	public const HOOK_CLICK_RETENTION_PURGE = 'affilun_cron_click_retention_purge';

	/**
	 * Custom cron interval key registered for click retention purge.
	 */
	public const INTERVAL_DAILY_CLICK_PURGE = 'affilun_click_retention_daily';

	/**
	 * Default Phase 2 freshness threshold (24 hours, in seconds).
	 *
	 * Mirrors the per-provider §9.3 `fresh_ttl` example. A future
	 * slice can lift this onto the settings surface; the constant
	 * here keeps the cron wiring self-contained until that lands.
	 */
	public const DEFAULT_STALE_AFTER_SECONDS = 86400;

	/**
	 * Default Phase 2 expiry threshold (7 days, in seconds).
	 *
	 * Mirrors the per-provider §9.3 `expired_ttl` example.
	 */
	public const DEFAULT_EXPIRED_AFTER_SECONDS = 604800;

	/**
	 * Resolved {@see SyncWorker} cached after register().
	 *
	 * Captured into the cron action handler closures so each tick
	 * uses the same instance the registry holds.
	 */
	private ?SyncWorker $worker = null;

	/**
	 * Resolved {@see PeriodicSyncScheduler} cached after register().
	 */
	private ?PeriodicSyncScheduler $scheduler = null;

	/**
	 * Resolved §10.4 {@see AuditRetentionPurger} cached after
	 * register(). Captured into the cron action handler closure
	 * so each tick uses the same instance the registry holds;
	 * stays null when the persistence-layer audit-event
	 * repository binding is missing (pure-unit harnesses that
	 * omit it keep working without modification, and the audit
	 * purge cron event is simply not scheduled in that case).
	 */
	private ?AuditRetentionPurger $auditRetentionPurger = null;

	/**
	 * Resolved {@see ClickRetentionPurger} cached after register().
	 */
	private ?ClickRetentionPurger $clickRetentionPurger = null;

	/**
	 * Resolved {@see OptionStore} cached after register() so the
	 * activation handler can persist the worker id without
	 * re-resolving the binding.
	 */
	private ?OptionStore $optionStore = null;

	/**
	 * Bind the cron-side collaborators (worker, scheduler) into the
	 * registry.
	 *
	 * Resolution order:
	 *
	 *   1. Resolve the persistence collaborators
	 *      ({@see ProductRepository}, {@see SyncJobRepository},
	 *      {@see OptionStore}) from
	 *      {@see PersistenceServiceProvider}'s bindings.
	 *
	 *   2. Resolve the {@see ProviderRegistry} from
	 *      {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}
	 *      and wrap it in a {@see RegistryBackedProviderResolver}.
	 *
	 *   3. Resolve the {@see CacheInvalidator} from
	 *      {@see CacheServiceProvider}'s binding when available; the
	 *      worker tolerates a missing invalidator (the handler
	 *      contract makes it optional) so a stripped-down bootstrap
	 *      that omits the cache provider still functions.
	 *
	 *   4. Default-construct {@see SystemClock} for the per-tick
	 *      "now" instant.
	 *
	 *   5. Resolve the worker id from the option store, generating
	 *      and persisting one when absent.
	 *
	 *   6. Compose the {@see SyncProductJobHandler}, the
	 *      {@see SyncWorker}, the {@see SyncPolicy}, the
	 *      {@see StaleProductFinder}, the {@see SyncDispatcher},
	 *      and the {@see PeriodicSyncScheduler}, caching each on
	 *      this provider so the cron action handler closures can
	 *      capture them.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When a required upstream binding is
	 *                        missing or the bound value has the
	 *                        wrong type.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		$products          = $this->resolveProductRepository( $registry );
		$jobs              = $this->resolveSyncJobRepository( $registry );
		$options           = $this->resolveOptionStore( $registry );
		$registryProviders = $this->resolveProviderRegistry( $registry );
		$invalidator       = $this->resolveCacheInvalidator( $registry );

		$clock    = new SystemClock();
		$resolver = new RegistryBackedProviderResolver( $registryProviders );

		$workerId = $this->resolveWorkerId( $options );

		$keywordExtractor = new CloudProductSearchKeywordsExtractor(
			new OptionStoreSettingsRepository( $options ),
			new FallbackProductSearchKeywordsExtractor()
		);

		$handler = new SyncProductJobHandler(
			$products,
			new SaveProductUseCase( $products, $clock, $keywordExtractor ),
			$jobs,
			$clock,
			RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT ),
			$resolver,
			$invalidator
		);

		$auditWriter = $this->resolveOptionalAuditEventWriter( $registry, $clock );

		$this->auditRetentionPurger = $this->resolveOptionalAuditRetentionPurger(
			$registry,
			$clock,
			$auditWriter
		);
		$this->clickRetentionPurger = $this->resolveOptionalClickRetentionPurger(
			$registry,
			$clock
		);

		$worker = new SyncWorker(
			$jobs,
			$handler,
			$clock,
			$workerId,
			SyncWorker::DEFAULT_BATCH_SIZE,
			SyncWorker::DEFAULT_LOCK_TIMEOUT_SECONDS,
			$auditWriter
		);

		$policy     = new SyncPolicy(
			self::DEFAULT_STALE_AFTER_SECONDS,
			self::DEFAULT_EXPIRED_AFTER_SECONDS,
			$clock
		);
		$finder     = new StaleProductFinder( $products, $policy );
		$dispatcher = new SyncDispatcher(
			$jobs,
			$clock,
			RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT )
		);

		$scheduler = new PeriodicSyncScheduler( $finder, $policy, $dispatcher, $clock );

		$this->worker      = $worker;
		$this->scheduler   = $scheduler;
		$this->optionStore = $options;
	}

	/**
	 * Wire the cron-related WordPress hooks through the supplied
	 * {@see HookRegistrar}:
	 *
	 *   - `cron_schedules` filter that registers the
	 *     {@see self::INTERVAL_EVERY_MINUTE} and
	 *     {@see self::INTERVAL_EVERY_FIVE_MINUTES} custom intervals.
	 *
	 *   - `affilun_activate` action that schedules both cron events.
	 *
	 *   - {@see self::HOOK_DISPATCH_JOBS} action that drives one
	 *     {@see SyncWorker::tick()} per fire.
	 *
	 *   - {@see self::HOOK_PERIODIC_SYNC} action that drives one
	 *     {@see PeriodicSyncScheduler::tick()} per fire.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		$hooks->addFilter(
			'cron_schedules',
			static function ( $schedules ): array {
				if ( ! is_array( $schedules ) ) {
					$schedules = array();
				}

				if ( ! array_key_exists( self::INTERVAL_EVERY_MINUTE, $schedules ) ) {
					$schedules[ self::INTERVAL_EVERY_MINUTE ] = array(
						'interval' => 60,
						'display'  => 'affilun: every minute',
					);
				}

				if ( ! array_key_exists( self::INTERVAL_EVERY_FIVE_MINUTES, $schedules ) ) {
					$schedules[ self::INTERVAL_EVERY_FIVE_MINUTES ] = array(
						'interval' => 300,
						'display'  => 'affilun: every five minutes',
					);
				}

				if ( ! array_key_exists( self::INTERVAL_DAILY_AUDIT_PURGE, $schedules ) ) {
					$schedules[ self::INTERVAL_DAILY_AUDIT_PURGE ] = array(
						'interval' => 86400,
						'display'  => 'affilun: daily audit retention purge',
					);
				}

				if ( ! array_key_exists( self::INTERVAL_DAILY_CLICK_PURGE, $schedules ) ) {
					$schedules[ self::INTERVAL_DAILY_CLICK_PURGE ] = array(
						'interval' => 86400,
						'display'  => 'affilun: daily click retention purge',
					);
				}

				return $schedules;
			}
		);

		$hooks->addAction(
			'affilun_activate',
			function (): void {
				$this->onActivate();
			}
		);
		$hooks->addAction(
			'init',
			function (): void {
				$this->ensureScheduled();
			}
		);

		$worker    = $this->worker;
		$scheduler = $this->scheduler;

		$hooks->addAction(
			self::HOOK_DISPATCH_JOBS,
			static function () use ( $worker ): void {
				if ( $worker === null ) {
					return;
				}
				try {
					$worker->tick();
				} catch ( Throwable $e ) {
					if ( function_exists( 'error_log' ) ) {
						// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional.
						error_log( 'AFFILUN SyncWorker tick failed: ' . $e->getMessage() );
					}
				}
			}
		);

		$hooks->addAction(
			self::HOOK_PERIODIC_SYNC,
			static function () use ( $scheduler ): void {
				if ( $scheduler === null ) {
					return;
				}
				try {
					$scheduler->tick();
				} catch ( Throwable $e ) {
					if ( function_exists( 'error_log' ) ) {
						// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional.
						error_log( 'AFFILUN PeriodicSyncScheduler tick failed: ' . $e->getMessage() );
					}
				}
			}
		);

		$auditRetentionPurger = $this->auditRetentionPurger;
		$clickRetentionPurger = $this->clickRetentionPurger;

		$hooks->addAction(
			self::HOOK_AUDIT_RETENTION_PURGE,
			static function () use ( $auditRetentionPurger ): void {
				if ( $auditRetentionPurger === null ) {
					return;
				}
				try {
					$auditRetentionPurger->purge();
				} catch ( Throwable $e ) {
					if ( function_exists( 'error_log' ) ) {
						// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional.
						error_log( 'AFFILUN AuditRetentionPurger purge failed: ' . $e->getMessage() );
					}
				}
			}
		);

		$hooks->addAction(
			self::HOOK_CLICK_RETENTION_PURGE,
			static function () use ( $clickRetentionPurger ): void {
				if ( $clickRetentionPurger === null ) {
					return;
				}
				try {
					$clickRetentionPurger->purge();
				} catch ( Throwable $e ) {
					if ( function_exists( 'error_log' ) ) {
						// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional.
						error_log( 'AFFILUN ClickRetentionPurger purge failed: ' . $e->getMessage() );
					}
				}
			}
		);
	}

	/**
	 * Activation entry point: persist the worker id (if absent) and
	 * schedule both cron events on their custom intervals.
	 *
	 * Idempotent: re-running activation never double-schedules an
	 * event because {@see wp_next_scheduled()} short-circuits the
	 * second call. The worker id is also preserved across
	 * deactivation / reactivation cycles so the §8.5 reclamation
	 * audit trail stays correlated.
	 */
	public function onActivate(): void {
		if ( $this->optionStore !== null ) {
			$this->resolveWorkerId( $this->optionStore );
		}

		$this->ensureScheduled();
	}

	/**
	 * Ensure existing installs have the required sync cron events.
	 *
	 * Plugin activation schedules these hooks, but WP-Cron rows can be
	 * removed independently by cache/cron management tools or migrations.
	 * Re-checking on normal bootstrap keeps the dispatch worker paired
	 * with the periodic enqueue sweep so stale products do not accumulate
	 * pending jobs indefinitely.
	 */
	public function ensureScheduled(): void {
		if ( function_exists( 'wp_next_scheduled' ) && function_exists( 'wp_schedule_event' ) ) {
			if ( ! wp_next_scheduled( self::HOOK_DISPATCH_JOBS ) ) {
				wp_schedule_event( time(), self::INTERVAL_EVERY_MINUTE, self::HOOK_DISPATCH_JOBS );
			}
			if ( ! wp_next_scheduled( self::HOOK_PERIODIC_SYNC ) ) {
				wp_schedule_event(
					time(),
					self::INTERVAL_EVERY_FIVE_MINUTES,
					self::HOOK_PERIODIC_SYNC
				);
			}
			if (
				$this->auditRetentionPurger !== null
				&& ! wp_next_scheduled( self::HOOK_AUDIT_RETENTION_PURGE )
			) {
				wp_schedule_event(
					time(),
					self::INTERVAL_DAILY_AUDIT_PURGE,
					self::HOOK_AUDIT_RETENTION_PURGE
				);
			}
			if (
				$this->clickRetentionPurger !== null
				&& ! wp_next_scheduled( self::HOOK_CLICK_RETENTION_PURGE )
			) {
				wp_schedule_event(
					time(),
					self::INTERVAL_DAILY_CLICK_PURGE,
					self::HOOK_CLICK_RETENTION_PURGE
				);
			}
		}
	}

	/**
	 * Deactivation entry point: unschedule both cron events.
	 *
	 * Exposed as a public method so the plugin entry file's
	 * deactivation hook can reach it through the bootstrap. The
	 * worker id option is INTENTIONALLY preserved so reactivation
	 * reuses the same identifier (see file-level docblock).
	 */
	public function onDeactivate(): void {
		if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_unschedule_event' ) ) {
			return;
		}

		foreach (
			array(
				self::HOOK_DISPATCH_JOBS,
				self::HOOK_PERIODIC_SYNC,
				self::HOOK_AUDIT_RETENTION_PURGE,
				self::HOOK_CLICK_RETENTION_PURGE,
			) as $hook
		) {
			$timestamp = wp_next_scheduled( $hook );
			while ( false !== $timestamp && null !== $timestamp ) {
				wp_unschedule_event( (int) $timestamp, $hook );
				$timestamp = wp_next_scheduled( $hook );
				if ( false === $timestamp || null === $timestamp ) {
					break;
				}
			}
		}
	}

	/**
	 * Worker bound after register(), or null when register() has not
	 * yet run (typical pure-unit test path).
	 */
	public function worker(): ?SyncWorker {
		return $this->worker;
	}

	/**
	 * Scheduler bound after register(), or null when register() has
	 * not yet run.
	 */
	public function scheduler(): ?PeriodicSyncScheduler {
		return $this->scheduler;
	}

	/**
	 * Resolve the §8.5 worker id from the option store, generating
	 * a fresh UUID-shaped value when none is persisted.
	 *
	 * @param OptionStore $options Option store binding.
	 */
	private function resolveWorkerId( OptionStore $options ): string {
		$existing = $options->get( self::OPTION_WORKER_ID );
		if ( is_string( $existing ) && $existing !== '' ) {
			return $existing;
		}

		$workerId = self::generateWorkerId();
		$options->update( self::OPTION_WORKER_ID, $workerId );
		return $workerId;
	}

	/**
	 * Generate a fresh stable worker identifier.
	 *
	 * Prefers `wp_generate_uuid4()` when available so the worker id
	 * follows the WordPress convention; falls back to a pure-PHP
	 * `random_bytes()` formatter otherwise. Truncates to 36
	 * characters so the value always fits inside the 64-byte
	 * `locked_by` column.
	 */
	private static function generateWorkerId(): string {
		if ( function_exists( 'wp_generate_uuid4' ) ) {
			$uuid = (string) wp_generate_uuid4();
		} else {
			$bytes    = random_bytes( 16 );
			$bytes[6] = chr( ( ord( $bytes[6] ) & 0x0F ) | 0x40 );
			$bytes[8] = chr( ( ord( $bytes[8] ) & 0x3F ) | 0x80 );
			$hex      = bin2hex( $bytes );
			$uuid     = sprintf(
				'%s-%s-%s-%s-%s',
				substr( $hex, 0, 8 ),
				substr( $hex, 8, 4 ),
				substr( $hex, 12, 4 ),
				substr( $hex, 16, 4 ),
				substr( $hex, 20, 12 )
			);
		}

		return 'affilun-' . $uuid;
	}

	/**
	 * Resolve {@see ProductRepository} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProductRepository}.
	 */
	private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository {
		$key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'SyncCronServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing ProductRepository binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

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

		return $value;
	}

	/**
	 * Resolve {@see SyncJobRepository} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see SyncJobRepository}.
	 */
	private function resolveSyncJobRepository( ServiceProviderRegistry $registry ): SyncJobRepository {
		$key = PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'SyncCronServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing SyncJobRepository binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

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

		return $value;
	}

	/**
	 * Resolve {@see OptionStore} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not an
	 *                        {@see OptionStore}.
	 */
	private function resolveOptionStore( ServiceProviderRegistry $registry ): OptionStore {
		$key = PersistenceServiceProvider::BINDING_OPTION_STORE;
		if ( ! $registry->has( $key ) ) {
			$message = 'SyncCronServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing OptionStore binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

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

		return $value;
	}

	/**
	 * Resolve {@see ProviderRegistry} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProviderRegistry}.
	 */
	private function resolveProviderRegistry( ServiceProviderRegistry $registry ): ProviderRegistry {
		$key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY;
		if ( ! $registry->has( $key ) ) {
			$message = 'SyncCronServiceProvider requires ProviderServiceProvider to register before it'
				. ' (missing ProviderRegistry binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

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

		return $value;
	}

	/**
	 * Resolve {@see CacheInvalidator} from the registry when
	 * available; return null when the cache provider is absent.
	 *
	 * The handler contract makes the invalidator optional, so a
	 * stripped-down bootstrap that omits the cache provider still
	 * yields a functional worker — the trade-off is that successful
	 * syncs will not invalidate the render cache until a future
	 * tick observes a freshly-bound invalidator.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveCacheInvalidator( ServiceProviderRegistry $registry ): ?CacheInvalidator {
		$key = CacheServiceProvider::BINDING_CACHE_INVALIDATOR;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

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

	/**
	 * Soft-resolve the {@see AuditEventRepository} from the
	 * registry and wrap it in a {@see RepositoryAuditEventWriter}
	 * so the §11 sync worker can emit `sync_job_terminal` audit
	 * rows for every handled job.
	 *
	 * Returns null when the binding is missing or carries an
	 * unexpected value — pure-unit harnesses that don't bind the
	 * audit repository keep working without modification, and the
	 * {@see SyncWorker} stays silent on the audit channel rather
	 * than failing loudly.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 * @param Clock                   $clock    Clock the writer
	 *                                          stamps `createdAt`
	 *                                          from.
	 */
	private function resolveOptionalAuditEventWriter(
		ServiceProviderRegistry $registry,
		Clock $clock
	): ?RepositoryAuditEventWriter {
		$key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$events = $registry->get( $key );
		if ( ! $events instanceof AuditEventRepository ) {
			return null;
		}

		return new RepositoryAuditEventWriter( $events, $clock );
	}

	/**
	 * Soft-resolve the {@see ClickEventRepository} and compose a click retention purger.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 * @param Clock                   $clock    Clock.
	 */
	private function resolveOptionalClickRetentionPurger(
		ServiceProviderRegistry $registry,
		Clock $clock
	): ?ClickRetentionPurger {
		$key = PersistenceServiceProvider::BINDING_CLICK_EVENT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}
		$events = $registry->get( $key );
		if ( ! $events instanceof ClickEventRepository ) {
			return null;
		}
		return new ClickRetentionPurger( $events, ClickRetentionPolicy::default(), $clock );
	}

	/**
	 * Soft-resolve the {@see AuditEventRepository} and compose audit retention purger.
	 *
	 * @param ServiceProviderRegistry $registry    Bootstrap registry.
	 * @param Clock                   $clock       Clock.
	 * @param AuditEventWriter|null   $auditWriter Optional writer.
	 */
	private function resolveOptionalAuditRetentionPurger(
		ServiceProviderRegistry $registry,
		Clock $clock,
		?AuditEventWriter $auditWriter
	): ?AuditRetentionPurger {
		$key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$events = $registry->get( $key );
		if ( ! $events instanceof AuditEventRepository ) {
			return null;
		}

		return new AuditRetentionPurger(
			$events,
			AuditRetentionPolicy::default(),
			$clock,
			$auditWriter
		);
	}
}
