<?php
// phpcs:ignoreFile -- Namespace and ABSPATH guard must stay at the top for Plugin Check.
namespace Affilun\Infrastructure\Persistence;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Persistence service provider.
 *
 * Wires the Phase 2 persistence layer into the affilun
 * bootstrap pipeline. The provider is deliberately narrow in scope:
 * it binds the collaborators that the persistence subsystem exposes
 * (option store, schema version repository, table name resolver,
 * schema installer, migration runner) during register(), and in
 * boot() subscribes the migration runner to the `affilun_activate` action
 * fired by {@see Plugin::onActivate()} so that the schema pipeline
 * runs exactly once per plugin activation.
 *
 * Lifecycle summary:
 *
 *   1. register(): instantiate the persistence collaborators and
 *      bind them into the
 *      {@see ServiceProviderRegistry} under stable string keys
 *      (see the `BINDING_*` class constants below). Collaborators are
 *      cached on the provider instance so subsequent resolvers — for
 *      example later providers that want to reuse the migration
 *      runner for a diagnostics surface — see the same object as the
 *      activation hook.
 *
 *   2. boot(): register a single `affilun_activate` action handler via
 *      the shared {@see HookRegistrar}. The handler calls
 *      {@see MigrationRunner::run()} and catches every {@see Throwable}
 *      to preserve the "fail loudly but do not leave the site half-
 *      activated" semantics of activation — see
 *      {@see self::handleActivate()} for the exact failure path.
 *
 * Resolution policy:
 *
 *   - Production callers construct the provider with an explicit
 *     {@see \wpdb} instance (typically the global `$wpdb`) so the
 *     {@see PrefixedTableNameResolver} picks up the correct prefix
 *     for the current site. The `$wpdb` argument is typed as
 *     `object` rather than `\wpdb` to keep the file loadable in
 *     pure-unit tests that run outside WordPress.
 *
 *   - The charset/collation clause appended to every `CREATE TABLE`
 *     statement is resolved from `$wpdb->get_charset_collate()` when
 *     the supplied object exposes that method. When it does not
 *     (unit tests, minimal test beds), the provider falls back to
 *     the empty string, which
 *     {@see \Affilun\Infrastructure\Persistence\Migrations\AbstractCreateTableMigration}
 *     resolves to the `DEFAULT CHARSET=utf8mb4` safe default.
 *
 *   - Every collaborator is constructed lazily on the first access
 *     so providers that inject pre-built collaborators (for example
 *     integration tests that want to observe migration runs against
 *     a recording schema installer) can override any one of them
 *     via the constructor without having to supply the rest.
 *
 * Failure semantics on activation:
 *
 *   - A successful migration run is silent; the diagnostics surface
 *     is the canonical place to inspect the applied version history.
 *
 *   - A migration that throws is re-raised through the host's
 *     `wp_die()` (when available) so the activation request aborts
 *     with a visible error message rather than leaving the plugin
 *     active with a half-applied schema. When `wp_die()` is not
 *     available (pure-unit tests, non-activation contexts that
 *     dispatch `affilun_activate` manually), the exception propagates
 *     verbatim so the test harness can assert on it.
 *
 * @package Affilun\Infrastructure\Persistence
 */
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table-existence checks use prepared SQL or quoted canonical table names.

use Affilun\Application\Audit\AuditEventDraft;
use Affilun\Application\Audit\RepositoryAuditEventWriter;
use Affilun\Application\AmazonDiscount\AmazonDiscountRepository;
use Affilun\Bootstrap\HookRegistrar;
use Affilun\Bootstrap\Plugin;
use Affilun\Bootstrap\ServiceProvider;
use Affilun\Bootstrap\ServiceProviderRegistry;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Persistence\Migrations\AddProductSearchKeywordsColumns;
use Affilun\Infrastructure\Persistence\Migrations\CreateAuditEventsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateAmazonDiscountProductsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateClickTables;
use Affilun\Infrastructure\Persistence\Migrations\CreateEmbedItemsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateEmbedsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateLogsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateProductMetaTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateProductsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateProductUrlsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateSyncJobsTable;
use Affilun\Infrastructure\Persistence\Migrations\CreateTopAffiliateCandidatesTable;
use Throwable;

/**
 * Binds the persistence-layer collaborators and wires the migration
 * runner to the `affilun_activate` action fired by
 * {@see Plugin::onActivate()}.
 *
 * See the file-level docblock for the lifecycle, resolution policy,
 * and activation-time failure semantics.
 */
final class PersistenceServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the {@see OptionStore} collaborator is
	 * bound.
	 *
	 * Consumers that need raw key/value access to the WordPress options
	 * table (outside the narrow schema-version case handled by the
	 * dedicated repository binding) should resolve this binding rather
	 * than instantiating {@see WpOptionStore} directly, so tests can
	 * swap in an {@see InMemoryOptionStore} by rebinding the same key.
	 */
	public const BINDING_OPTION_STORE = 'persistence.option_store';

	/**
	 * Registry key under which the {@see SchemaVersionRepository} is
	 * bound.
	 */
	public const BINDING_SCHEMA_VERSION_REPOSITORY = 'persistence.schema_version_repository';

	/**
	 * Registry key under which the {@see TableNameResolver} is bound.
	 */
	public const BINDING_TABLE_NAME_RESOLVER = 'persistence.table_name_resolver';

	/**
	 * Registry key under which the {@see SchemaInstaller} is bound.
	 */
	public const BINDING_SCHEMA_INSTALLER = 'persistence.schema_installer';

	/**
	 * Registry key under which the fully-populated {@see MigrationRunner}
	 * is bound.
	 *
	 * The runner returned by this binding has every initial-release
	 * migration already registered, so callers can invoke
	 * {@see MigrationRunner::run()} directly without re-registering
	 * the pipeline.
	 */
	public const BINDING_MIGRATION_RUNNER = 'persistence.migration_runner';

	/**
	 * Registry key under which the {@see ProductRepository} is bound.
	 *
	 * Production wiring returns a {@see WpdbProductRepository} derived
	 * from the injected `$wpdb` and the shared
	 * {@see TableNameResolver}; tests that want to exercise the
	 * activation path without a live database can inject an
	 * {@see InMemoryProductRepository} via the constructor override.
	 */
	public const BINDING_PRODUCT_REPOSITORY = 'persistence.product_repository';

	/**
	 * Registry key under which the {@see TopAffiliateCandidateRepository} is bound.
	 */
	public const BINDING_TOP_AFFILIATE_CANDIDATE_REPOSITORY = 'persistence.top_affiliate_candidate_repository';

	/**
	 * Registry key for daily Amazon discount snapshots.
	 */
	public const BINDING_AMAZON_DISCOUNT_REPOSITORY = 'persistence.amazon_discount_repository';

	/**
	 * Registry key under which the {@see EmbedRepository} is bound.
	 *
	 * Production wiring returns a {@see WpdbEmbedRepository} derived
	 * from the injected `$wpdb` and the shared
	 * {@see TableNameResolver}; tests that want to exercise the
	 * activation path without a live database can inject an
	 * {@see InMemoryEmbedRepository} via the constructor override.
	 */
	public const BINDING_EMBED_REPOSITORY = 'persistence.embed_repository';

	/**
	 * Registry key under which the {@see EmbedItemRepository} is bound.
	 *
	 * Phase 2 KI-002 addition. Production wiring returns a
	 * {@see WpdbEmbedItemRepository} derived from the injected
	 * `$wpdb` and the shared {@see TableNameResolver}; tests that
	 * want to exercise the activation path without a live database
	 * can inject an {@see InMemoryEmbedItemRepository} via the
	 * constructor override.
	 */
	public const BINDING_EMBED_ITEM_REPOSITORY = 'persistence.embed_item_repository';

	/**
	 * Registry key under which the {@see AuditEventRepository} is
	 * bound.
	 *
	 * Phase 2 KI-006 addition. Production wiring returns a
	 * {@see WpdbAuditEventRepository} derived from the injected
	 * `$wpdb` and the shared {@see TableNameResolver}; tests that
	 * want to exercise the activation path without a live database
	 * can inject an {@see InMemoryAuditEventRepository} via the
	 * constructor override.
	 */
	public const BINDING_AUDIT_EVENT_REPOSITORY = 'persistence.audit_event_repository';

	/**
	 * Registry key under which the {@see SyncJobRepository} is bound.
	 *
	 * Production wiring returns a {@see WpdbSyncJobRepository} derived
	 * from the injected `$wpdb` and the shared
	 * {@see TableNameResolver}; tests that want to exercise the
	 * activation path without a live database can inject an
	 * {@see InMemorySyncJobRepository} via the constructor override.
	 */
	public const BINDING_SYNC_JOB_REPOSITORY = 'persistence.sync_job_repository';

	/**
	 * Registry key under which the {@see LogRepository} is bound.
	 *
	 * Production wiring returns a {@see WpdbLogRepository} derived
	 * from the injected `$wpdb` and the shared
	 * {@see TableNameResolver}; tests that want to exercise the
	 * activation path without a live database can inject an
	 * {@see InMemoryLogRepository} via the constructor override.
	 */
	public const BINDING_LOG_REPOSITORY = 'persistence.log_repository';

	/**
	 * Registry key under which the {@see ProductMetaRepository} is bound.
	 *
	 * Production wiring returns a {@see WpdbProductMetaRepository}
	 * derived from the injected `$wpdb` and the shared
	 * {@see TableNameResolver}; tests that want to exercise the
	 * activation path without a live database can inject an
	 * {@see InMemoryProductMetaRepository} via the constructor
	 * override.
	 */
	public const BINDING_PRODUCT_META_REPOSITORY = 'persistence.product_meta_repository';

	/**
	 * Registry key under which the {@see ProductUrlRepository} is bound.
	 */
	public const BINDING_PRODUCT_URL_REPOSITORY = 'persistence.product_url_repository';

	/**
	 * Registry key under which the {@see ClickEventRepository} is bound.
	 */
	public const BINDING_CLICK_EVENT_REPOSITORY = 'persistence.click_event_repository';

	/**
	 * Registry key under which the {@see TransactionManager} is bound.
	 *
	 * Production wiring returns a {@see WpdbTransactionManager} that
	 * brackets units of work with the WordPress `$wpdb` connection's
	 * transaction primitives; tests that want to exercise the
	 * boundary without a live database can inject an
	 * {@see InMemoryTransactionManager} via the constructor override.
	 */
	public const BINDING_TRANSACTION_MANAGER = 'persistence.transaction_manager';

	/**
	 * WordPress `$wpdb`-like object used to resolve the table prefix
	 * and the charset/collation clause, or null when the provider was
	 * constructed without one.
	 *
	 * Typed as `object` rather than the concrete `\wpdb` class so this
	 * file stays trivially loadable in pure-unit tests that run
	 * outside a WordPress environment.
	 */
	private ?object $wpdb;

	/**
	 * Option store used to back the schema version repository.
	 *
	 * Injected for test reuse; null triggers construction of a
	 * {@see WpOptionStore} on first access.
	 */
	private ?OptionStore $optionStore;

	/**
	 * Schema installer each migration forwards its DDL to.
	 *
	 * Injected for test reuse; null triggers construction of a
	 * {@see WpdbSchemaInstaller} on first access.
	 */
	private ?SchemaInstaller $schemaInstaller;

	/**
	 * Table name resolver shared by every migration registered on the
	 * runner.
	 *
	 * Injected for test reuse; null triggers construction of a
	 * {@see PrefixedTableNameResolver} derived from the injected
	 * `$wpdb` prefix on first access.
	 */
	private ?TableNameResolver $tableNames;

	/**
	 * Charset/collation clause override.
	 *
	 * When null, the provider asks `$wpdb->get_charset_collate()` at
	 * resolution time (and falls back to the empty string when that
	 * method is not available). Tests and diagnostics that need a
	 * deterministic clause may supply an explicit string here.
	 */
	private ?string $charsetCollate;

	/**
	 * Product repository override.
	 *
	 * When null, the provider constructs a {@see WpdbProductRepository}
	 * on first access using the injected `$wpdb` and the shared
	 * {@see TableNameResolver}. Tests may inject an
	 * {@see InMemoryProductRepository} (or any other
	 * {@see ProductRepository}) to exercise consumers without a live
	 * database.
	 */
	private ?ProductRepository $productRepository;

	/**
	 * Cloud top affiliate candidate repository override.
	 */
	private ?TopAffiliateCandidateRepository $topAffiliateCandidateRepository;

	/**
	 * Amazon discount repository override.
	 */
	private ?AmazonDiscountRepository $amazonDiscountRepository;

	/**
	 * Embed repository override.
	 *
	 * When null, the provider constructs a {@see WpdbEmbedRepository}
	 * on first access using the injected `$wpdb` and the shared
	 * {@see TableNameResolver}. Tests may inject an
	 * {@see InMemoryEmbedRepository} (or any other
	 * {@see EmbedRepository}) to exercise consumers without a live
	 * database.
	 */
	private ?EmbedRepository $embedRepository;

	/**
	 * Embed-item repository override.
	 *
	 * When null, the provider constructs a
	 * {@see WpdbEmbedItemRepository} on first access using the
	 * injected `$wpdb` and the shared {@see TableNameResolver}.
	 * Tests may inject an {@see InMemoryEmbedItemRepository} (or
	 * any other {@see EmbedItemRepository}) to exercise consumers
	 * without a live database.
	 */
	private ?EmbedItemRepository $embedItemRepository;

	/**
	 * Audit-event repository override.
	 *
	 * When null, the provider constructs a
	 * {@see WpdbAuditEventRepository} on first access using the
	 * injected `$wpdb` and the shared {@see TableNameResolver}.
	 * Tests may inject an {@see InMemoryAuditEventRepository}
	 * (or any other {@see AuditEventRepository}) to exercise
	 * consumers without a live database.
	 */
	private ?AuditEventRepository $auditEventRepository;

	/**
	 * Sync-job repository override.
	 *
	 * When null, the provider constructs a {@see WpdbSyncJobRepository}
	 * on first access using the injected `$wpdb` and the shared
	 * {@see TableNameResolver}. Tests may inject an
	 * {@see InMemorySyncJobRepository} (or any other
	 * {@see SyncJobRepository}) to exercise consumers without a live
	 * database.
	 */
	private ?SyncJobRepository $syncJobRepository;

	/**
	 * Log repository override.
	 *
	 * When null, the provider constructs a {@see WpdbLogRepository}
	 * on first access using the injected `$wpdb` and the shared
	 * {@see TableNameResolver}. Tests may inject an
	 * {@see InMemoryLogRepository} (or any other
	 * {@see LogRepository}) to exercise consumers without a live
	 * database.
	 */
	private ?LogRepository $logRepository;

	/**
	 * Product-meta repository override.
	 *
	 * When null, the provider constructs a
	 * {@see WpdbProductMetaRepository} on first access using the
	 * injected `$wpdb` and the shared {@see TableNameResolver}. Tests
	 * may inject an {@see InMemoryProductMetaRepository} (or any
	 * other {@see ProductMetaRepository}) to exercise consumers
	 * without a live database.
	 */
	private ?ProductMetaRepository $productMetaRepository;

	/**
	 * Product URL repository override.
	 */
	private ?ProductUrlRepository $productUrlRepository;

	/**
	 * Transaction manager override.
	 *
	 * When null, the provider constructs a
	 * {@see WpdbTransactionManager} on first access using the
	 * injected `$wpdb`. Tests may inject an
	 * {@see InMemoryTransactionManager} (or any other
	 * {@see TransactionManager}) to exercise consumers without a
	 * live database.
	 */
	private ?TransactionManager $transactionManager;

	/**
	 * Cached schema version repository returned by the registry binding.
	 */
	private ?SchemaVersionRepository $schemaVersionRepository = null;

	/**
	 * Cached migration runner returned by the registry binding.
	 *
	 * Populated lazily in {@see self::resolveMigrationRunner()} so that
	 * both the registry binding and the activation handler see the
	 * same runner instance.
	 */
	private ?MigrationRunner $migrationRunner = null;

	/**
	 * Cached one-shot migrator for pre-0.7.0 `afl_*` storage.
	 */
	private ?LegacyPrefixDataMigrator $legacyPrefixDataMigrator = null;

	/**
	 * Construct the provider.
	 *
	 * Every collaborator is optional; omitting one causes the provider
	 * to build the production default on first access. This shape
	 * keeps the production wiring ergonomic
	 * (`new PersistenceServiceProvider( $wpdb )`) while still allowing
	 * integration tests to inject synthetic collaborators one axis at
	 * a time.
	 *
	 * @param object|null                          $wpdb            WordPress `$wpdb`-like
	 *                                                              object used to derive
	 *                                                              the table prefix and
	 *                                                              the charset/collation
	 *                                                              clause. Required
	 *                                                              when no explicit
	 *                                                              {@see TableNameResolver}
	 *                                                              is injected.
	 * @param OptionStore|null                     $optionStore     Explicit option store.
	 *                                                              Defaults to a
	 *                                                              {@see WpOptionStore}.
	 * @param SchemaInstaller|null                 $schemaInstaller Explicit installer.
	 *                                                              Defaults to a
	 *                                                              {@see WpdbSchemaInstaller}.
	 * @param TableNameResolver|null               $tableNames     Explicit resolver.
	 *                                                             Defaults to
	 *                                                             {@see PrefixedTableNameResolver::fromWpdb()}.
	 * @param string|null                          $charsetCollate    Explicit charset /
	 *                                                                collation clause.
	 *                                                                Defaults to
	 *                                                                `$wpdb->get_charset_collate()`
	 *                                                                when available.
	 * @param ProductRepository|null               $productRepository Explicit product
	 *                                                                repository. Defaults
	 *                                                                to a
	 *                                                                {@see WpdbProductRepository}
	 *                                                                wired against the
	 *                                                                injected `$wpdb`.
	 * @param EmbedRepository|null                 $embedRepository   Explicit embed
	 *                                                                repository. Defaults
	 *                                                                to a
	 *                                                                {@see WpdbEmbedRepository}
	 *                                                                wired against the
	 *                                                                injected `$wpdb`.
	 * @param SyncJobRepository|null               $syncJobRepository Explicit sync-job
	 *                                                                repository. Defaults
	 *                                                                to a
	 *                                                                {@see WpdbSyncJobRepository}
	 *                                                                wired against the
	 *                                                                injected `$wpdb`.
	 * @param LogRepository|null                   $logRepository     Explicit log
	 *                                                                repository. Defaults
	 *                                                                to a
	 *                                                                {@see WpdbLogRepository}
	 *                                                                wired against the
	 *                                                                injected `$wpdb`.
	 * @param ProductMetaRepository|null           $productMetaRepository Explicit
	 *                                                                    product-meta
	 *                                                                    repository.
	 *                                                                    Defaults to a
	 *                                                                    {@see WpdbProductMetaRepository}
	 *                                                                    wired against
	 *                                                                    the injected
	 *                                                                    `$wpdb`.
	 * @param TransactionManager|null              $transactionManager    Explicit
	 *                                                                    transaction
	 *                                                                    manager.
	 *                                                                    Defaults to a
	 *                                                                    {@see WpdbTransactionManager}
	 *                                                                    wired against
	 *                                                                    the injected
	 *                                                                    `$wpdb`.
	 * @param EmbedItemRepository|null             $embedItemRepository   Explicit
	 *                                                                    embed-item
	 *                                                                    repository
	 *                                                                    (Phase 2 KI-002).
	 *                                                                    Defaults to a
	 *                                                                    {@see WpdbEmbedItemRepository}
	 *                                                                    wired against
	 *                                                                    the injected
	 *                                                                    `$wpdb`.
	 * @param AuditEventRepository|null            $auditEventRepository  Explicit
	 *                                                                    audit-event
	 *                                                                    repository
	 *                                                                    (Phase 2 KI-006).
	 *                                                                    Defaults to a
	 *                                                                    {@see WpdbAuditEventRepository}
	 *                                                                    wired against
	 *                                                                    the injected
	 *                                                                    `$wpdb`.
	 * @param ProductUrlRepository|null            $productUrlRepository  Explicit product URL repository.
	 * @param TopAffiliateCandidateRepository|null $topAffiliateCandidateRepository Explicit Cloud candidate repository.
	 */
	public function __construct(
		?object $wpdb = null,
		?OptionStore $optionStore = null,
		?SchemaInstaller $schemaInstaller = null,
		?TableNameResolver $tableNames = null,
		?string $charsetCollate = null,
		?ProductRepository $productRepository = null,
		?EmbedRepository $embedRepository = null,
		?SyncJobRepository $syncJobRepository = null,
		?LogRepository $logRepository = null,
		?ProductMetaRepository $productMetaRepository = null,
		?TransactionManager $transactionManager = null,
		?EmbedItemRepository $embedItemRepository = null,
		?AuditEventRepository $auditEventRepository = null,
		?ProductUrlRepository $productUrlRepository = null,
		?TopAffiliateCandidateRepository $topAffiliateCandidateRepository = null,
		?AmazonDiscountRepository $amazonDiscountRepository = null
	) {
		$this->wpdb                            = $wpdb;
		$this->optionStore                     = $optionStore;
		$this->schemaInstaller                 = $schemaInstaller;
		$this->embedItemRepository             = $embedItemRepository;
		$this->auditEventRepository            = $auditEventRepository;
		$this->tableNames                      = $tableNames;
		$this->charsetCollate                  = $charsetCollate;
		$this->productRepository               = $productRepository;
		$this->topAffiliateCandidateRepository = $topAffiliateCandidateRepository;
		$this->amazonDiscountRepository        = $amazonDiscountRepository;
		$this->embedRepository                 = $embedRepository;
		$this->syncJobRepository               = $syncJobRepository;
		$this->logRepository                   = $logRepository;
		$this->productMetaRepository           = $productMetaRepository;
		$this->productUrlRepository            = $productUrlRepository;
		$this->transactionManager              = $transactionManager;
	}

	/**
	 * Bind every persistence collaborator into the registry.
	 *
	 * Resolution is deliberately eager at this point so that a
	 * misconfiguration (for example a missing `$wpdb` when no
	 * {@see TableNameResolver} was injected) surfaces during
	 * {@see ServiceProviderRegistry::registerAll()} rather than on
	 * the first activation attempt.
	 *
	 * @param ServiceProviderRegistry $registry Registry driving this
	 *                                          bootstrap pass.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		$registry->bind( self::BINDING_OPTION_STORE, $this->resolveOptionStore() );
		$registry->bind( self::BINDING_SCHEMA_VERSION_REPOSITORY, $this->resolveSchemaVersionRepository() );
		$registry->bind( self::BINDING_TABLE_NAME_RESOLVER, $this->resolveTableNames() );
		$registry->bind( self::BINDING_SCHEMA_INSTALLER, $this->resolveSchemaInstaller() );
		$registry->bind( self::BINDING_MIGRATION_RUNNER, $this->resolveMigrationRunner() );
		$registry->bind( self::BINDING_PRODUCT_REPOSITORY, $this->resolveProductRepository() );
		$registry->bind( self::BINDING_TOP_AFFILIATE_CANDIDATE_REPOSITORY, $this->resolveTopAffiliateCandidateRepository() );
		$registry->bind( self::BINDING_AMAZON_DISCOUNT_REPOSITORY, $this->resolveAmazonDiscountRepository() );
		$registry->bind( self::BINDING_EMBED_REPOSITORY, $this->resolveEmbedRepository() );
		$registry->bind( self::BINDING_EMBED_ITEM_REPOSITORY, $this->resolveEmbedItemRepository() );
		$registry->bind( self::BINDING_AUDIT_EVENT_REPOSITORY, $this->resolveAuditEventRepository() );
		$registry->bind( self::BINDING_SYNC_JOB_REPOSITORY, $this->resolveSyncJobRepository() );
		$registry->bind( self::BINDING_LOG_REPOSITORY, $this->resolveLogRepository() );
		$registry->bind( self::BINDING_PRODUCT_META_REPOSITORY, $this->resolveProductMetaRepository() );
		$registry->bind( self::BINDING_PRODUCT_URL_REPOSITORY, $this->resolveProductUrlRepository() );
		$registry->bind( self::BINDING_CLICK_EVENT_REPOSITORY, $this->resolveClickEventRepository() );
		$registry->bind( self::BINDING_TRANSACTION_MANAGER, $this->resolveTransactionManager() );
	}

	/**
	 * Subscribe the migration runner to the `affilun_activate` action.
	 *
	 * A single action handler is registered at the default priority;
	 * it forwards to {@see self::handleActivate()} which owns the
	 * failure path. Registering via the {@see HookRegistrar} keeps
	 * the wiring inspectable by tests and by the diagnostics surface.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		$hooks->addAction(
			'affilun_activate',
			function ( $plugin = null ): void {
				$this->handleActivate( $plugin instanceof Plugin ? $plugin : null );
			}
		);
		$hooks->addAction( 'admin_init', array( $this, 'onRuntimeSchemaMaintenance' ), 10, 0 );
		$hooks->addAction( 'rest_api_init', array( $this, 'onRuntimeSchemaMaintenance' ), 1, 0 );
	}

	/**
	 * Run pending schema migrations during normal admin / REST requests.
	 *
	 * This covers plugin update flows where WordPress replaces the
	 * plugin files without firing the activation hook. The handler is
	 * intentionally lightweight on up-to-date sites: it reads the stored
	 * schema version, compares it to the highest registered migration,
	 * and returns without issuing DDL when no migration is pending.
	 */
	public function onRuntimeSchemaMaintenance(): void {
		try {
			$runner  = $this->resolveMigrationRunner();
			$current = $this->resolveSchemaVersionRepository()->currentVersion();
			$target  = $runner->highestKnownVersion();

			if ( $current->toInt() < $target->toInt() ) {
				$result = $runner->run();
				$this->emitMigrationAudit( $result, null );
			}

			$this->runLegacyPrefixDataMigration();
		} catch ( Throwable $error ) {
			$this->emitMigrationAudit( null, $error );
			$this->reportRuntimeMigrationFailure( $error );
		}
	}

	/**
	 * Migration runner bound into the registry, populated with every
	 * initial-release migration.
	 *
	 * Exposed so integration tests and diagnostics surfaces can run or
	 * introspect the pipeline without having to resolve the binding
	 * through the registry.
	 */
	public function migrationRunner(): MigrationRunner {
		return $this->resolveMigrationRunner();
	}

	/**
	 * Product repository bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same repository instance that consumers resolve
	 * through {@see self::BINDING_PRODUCT_REPOSITORY}.
	 */
	public function productRepository(): ProductRepository {
		return $this->resolveProductRepository();
	}

	/**
	 * Embed repository bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same repository instance that consumers resolve
	 * through {@see self::BINDING_EMBED_REPOSITORY}.
	 */
	public function embedRepository(): EmbedRepository {
		return $this->resolveEmbedRepository();
	}

	/**
	 * Embed-item repository bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same repository instance that consumers resolve
	 * through {@see self::BINDING_EMBED_ITEM_REPOSITORY}.
	 */
	public function embedItemRepository(): EmbedItemRepository {
		return $this->resolveEmbedItemRepository();
	}

	/**
	 * Audit-event repository bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same repository instance that consumers resolve
	 * through {@see self::BINDING_AUDIT_EVENT_REPOSITORY}.
	 */
	public function auditEventRepository(): AuditEventRepository {
		return $this->resolveAuditEventRepository();
	}

	/**
	 * Sync-job repository bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same repository instance that consumers resolve
	 * through {@see self::BINDING_SYNC_JOB_REPOSITORY}.
	 */
	public function syncJobRepository(): SyncJobRepository {
		return $this->resolveSyncJobRepository();
	}

	/**
	 * Log repository bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same repository instance that consumers resolve
	 * through {@see self::BINDING_LOG_REPOSITORY}.
	 */
	public function logRepository(): LogRepository {
		return $this->resolveLogRepository();
	}

	/**
	 * Product-meta repository bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same repository instance that consumers resolve
	 * through {@see self::BINDING_PRODUCT_META_REPOSITORY}.
	 */
	public function productMetaRepository(): ProductMetaRepository {
		return $this->resolveProductMetaRepository();
	}

	/**
	 * Product URL repository bound into the registry.
	 */
	public function productUrlRepository(): ProductUrlRepository {
		return $this->resolveProductUrlRepository();
	}

	/**
	 * Transaction manager bound into the registry.
	 *
	 * Exposed so the activation flow and diagnostics surfaces can
	 * reach the same manager instance that consumers resolve through
	 * {@see self::BINDING_TRANSACTION_MANAGER}. Sharing a single
	 * instance is required because the manager owns the
	 * `inTransaction` flag on itself rather than on `$wpdb`; see
	 * {@see WpdbTransactionManager} for the rationale.
	 */
	public function transactionManager(): TransactionManager {
		return $this->resolveTransactionManager();
	}

	/**
	 * Schema version repository bound into the registry.
	 *
	 * Exposed for the same reason as {@see self::migrationRunner()} —
	 * diagnostics surfaces frequently want to report the current
	 * schema version without re-deriving the repository.
	 */
	public function schemaVersionRepository(): SchemaVersionRepository {
		return $this->resolveSchemaVersionRepository();
	}

	/**
	 * Run the migration pipeline in response to `affilun_activate`.
	 *
	 * The handler deliberately swallows no exception: on a thrown
	 * migration we route through `wp_die()` (when available) so the
	 * activation request aborts with a visible error rather than
	 * leaving the plugin active with a half-applied schema. When
	 * `wp_die()` is not available — pure-unit tests that dispatch
	 * `affilun_activate` manually without the WordPress stub — the
	 * exception propagates verbatim so the test harness can assert
	 * on it.
	 *
	 * The `$plugin` argument is accepted for signature parity with
	 * the action dispatched by {@see Plugin::onActivate()} but is not
	 * currently consumed; it is kept here so future activation-time
	 * work (for example capability grants) can reach the plugin
	 * instance without a signature change.
	 *
	 * @param Plugin|null $plugin Plugin instance forwarded by the
	 *                            `affilun_activate` action, or null when
	 *                            the handler is invoked outside the
	 *                            normal activation path.
	 *
	 * @throws Throwable Re-thrown from the failing migration when
	 *                   `wp_die()` is not available (pure-unit test
	 *                   context).
	 */
	private function handleActivate( ?Plugin $plugin = null ): void {
		unset( $plugin );

		$result = null;

		try {
			$result = $this->resolveMigrationRunner()->run();
			$this->runLegacyPrefixDataMigration();
		} catch ( Throwable $error ) {
			$this->emitMigrationAudit( null, $error );

			if ( ! function_exists( 'wp_die' ) ) {
				throw $error;
			}

			$message = sprintf(
				'affilun: schema migration failed during activation: %s',
				$error->getMessage()
			);

			$title = function_exists( 'esc_html__' )
				? (string) esc_html__( 'affilun の有効化を中止しました', 'affilun' )
				: 'affilun の有効化を中止しました';

			wp_die(
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped above via esc_html() when available.
				function_exists( 'esc_html' ) ? (string) esc_html( $message ) : $message,
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped above via esc_html__() when available.
				$title,
				array( 'back_link' => true )
			);
		}

		if ( $result !== null ) {
			$this->emitMigrationAudit( $result, null );
		}
	}

	/**
	 * Report a runtime migration failure without exposing sensitive data.
	 *
	 * @param Throwable $error Migration failure.
	 */
	private function reportRuntimeMigrationFailure( Throwable $error ): void {
		$message = sprintf(
			'affilun: schema migration failed during runtime maintenance: %s',
			self::truncate( $error->getMessage(), 500 )
		);

		if ( function_exists( 'error_log' ) ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Runtime migration failures require operator visibility.
			error_log( $message );
		}

		if ( ! function_exists( 'add_action' ) ) {
			return;
		}

		add_action(
			'admin_notices',
			static function () use ( $message ): void {
				if ( function_exists( 'current_user_can' ) && ! current_user_can( 'manage_options' ) ) {
					return;
				}

				$escaped = function_exists( 'esc_html' )
					? (string) esc_html( $message )
					: htmlspecialchars( $message, ENT_QUOTES, 'UTF-8' );

				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped immediately above.
				echo '<div class="notice notice-error"><p>' . $escaped . '</p></div>';
			}
		);
	}

	/**
	 * Emit a §10.1 `configuration_change` audit row describing the
	 * activation-time migration outcome.
	 *
	 * Three branches are supported:
	 *
	 *   - Success with `didAdvance() === true`: emit a row with
	 *     {@see AuditEventDraft::RESULT_SUCCESS}, target_id =
	 *     `"schema:" . $endVersion`, and context carrying the
	 *     start / end version pair plus the applied target-version
	 *     list so an operator can reconstruct exactly which steps
	 *     ran.
	 *
	 *   - No-op (`isNoop() === true`): emit nothing. Repeat
	 *     activations would otherwise stamp a redundant row on every
	 *     `affilun_activate` dispatch even though no schema mutation
	 *     occurred — that contradicts the §10.1 "audit-worthy" rule
	 *     and would noise up the read surface.
	 *
	 *   - Failure (`$error !== null`): emit a row with
	 *     {@see AuditEventDraft::RESULT_FAILED}, an error_class /
	 *     error_message context, and target_id =
	 *     `"schema:" . $startVersion` (the version the site is
	 *     stuck at after the failure). The audit row is recorded
	 *     BEFORE the surrounding wp_die() / re-raise so a fatal
	 *     activation cannot lose the audit trail.
	 *
	 * Audit emission requires both an audit-event repository binding
	 * resolvable through {@see self::resolveAuditEventRepository()}
	 * AND that {@see self::isAuditTableInstalled()} return true (the
	 * `affilun_audit_events` table physically exists). When either
	 * precondition is missing — typical first-activation case where
	 * the migration that creates the table either has not yet run or
	 * just failed mid-pipeline before the audit table itself
	 * landed — the emit is silently skipped per the
	 * {@see RepositoryAuditEventWriter} "tolerate audit emit failure"
	 * contract: losing one activation audit row is preferable to
	 * losing the activation result it described.
	 *
	 * @param MigrationResult|null $result Successful migration
	 *                                     outcome, or null on
	 *                                     failure.
	 * @param Throwable|null       $error  Migration failure, or
	 *                                     null on success.
	 */
	private function emitMigrationAudit( ?MigrationResult $result, ?Throwable $error ): void {
		if ( $result === null && $error === null ) {
			return;
		}

		if ( $result !== null && $result->isNoop() ) {
			return;
		}

		if ( ! $this->isAuditTableInstalled() ) {
			return;
		}

		try {
			$events = $this->resolveAuditEventRepository();
		} catch ( Throwable $unused ) {
			unset( $unused );
			return;
		}

		$writer = new RepositoryAuditEventWriter( $events, new SystemClock() );

		try {
			if ( $error !== null ) {
				$startVersion = 0;
				if ( $result !== null ) {
					$startVersion = $result->startVersion()->toInt();
				}

				$draft = AuditEventDraft::forSystemAction(
					'schema.migration',
					'configuration_change',
					'migrate',
					AuditEventDraft::RESULT_FAILED,
					'Schema migration failed during plugin activation.',
					'schema',
					'schema:' . $startVersion,
					array(),
					array(
						'error_class'   => get_class( $error ),
						'error_message' => self::truncate( $error->getMessage(), 500 ),
					)
				);
			} else {
				/** @var MigrationResult $result */
				$startVersion = $result->startVersion()->toInt();
				$endVersion   = $result->endVersion()->toInt();

				$draft = AuditEventDraft::forSystemAction(
					'schema.migration',
					'configuration_change',
					'migrate',
					AuditEventDraft::RESULT_SUCCESS,
					sprintf(
						'Schema migration advanced from version %d to %d (%d step(s)).',
						$startVersion,
						$endVersion,
						$result->appliedCount()
					),
					'schema',
					'schema:' . $endVersion,
					array(
						'start_version'    => $startVersion,
						'end_version'      => $endVersion,
						'applied_versions' => $result->appliedVersions(),
					),
					array(
						'applied_count' => $result->appliedCount(),
					)
				);
			}

			$writer->record( $draft );
		} catch ( Throwable $unused ) {
			// Per the AuditEventWriter contract, audit emit failure
			// must not derail the surrounding business outcome
			// (activation success / wp_die path). Swallow and
			// continue so the migration result is what the operator
			// observes.
			unset( $unused );
		}
	}

	/**
	 * Probe whether the `affilun_audit_events` table physically exists
	 * before attempting to emit an audit row.
	 *
	 * Activation is the one moment in the plugin lifecycle when the
	 * audit-events table may legitimately be absent — the migration
	 * that creates it (`CreateAuditEventsTable`, schema v7) is part
	 * of the very pipeline whose outcome we are trying to record.
	 * On a brand-new installation the failure branch could fire
	 * before that migration runs, and even on a successful
	 * advance-from-zero run the writer would race the table's own
	 * creation if we did not gate.
	 *
	 * Returns true when `$wpdb->get_var( "SHOW TABLES LIKE ... )"`
	 * returns the resolved physical table name, false otherwise.
	 * Any driver error during the probe collapses to false so
	 * audit emission silently degrades rather than masking the
	 * activation outcome.
	 */
	private function isAuditTableInstalled(): bool {
		// Pure-unit / in-memory wiring path: there is no `$wpdb` to
		// probe against, but the in-memory audit repository the
		// provider was constructed with is fully functional. Treat
		// the table as installed so unit tests can assert on
		// emission behavior; the production wp-env smoke path
		// always carries a real `$wpdb` so this branch is exercised
		// only in tests.
		if ( $this->wpdb === null ) {
			return true;
		}

		try {
			$tableName = $this->resolveTableNames()->resolve( 'affilun_audit_events' );
		} catch ( Throwable $unused ) {
			unset( $unused );
			return false;
		}

		$wpdb = $this->wpdb;
		if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) ) {
			return false;
		}

		try {
			if ( ! method_exists( $wpdb, 'prepare' ) ) {
				return false;
			}
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepare() invocation immediately below produces the prepared SQL.
			$sql = $wpdb->prepare( 'SHOW TABLES LIKE %s', $tableName );
			if ( ! is_string( $sql ) ) {
				return false;
			}
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql is already prepared via $wpdb->prepare() above.
			$found = $wpdb->get_var( $sql );
		} catch ( Throwable $unused ) {
			unset( $unused );
			return false;
		}

		return is_string( $found ) && $found === $tableName;
	}

	/**
	 * Truncate a string to at most `$max` bytes, appending an
	 * ellipsis when truncation occurred.
	 *
	 * Used to bound the `error_message` slot on the failure-branch
	 * audit context so a verbose stack-trace-style exception
	 * message cannot bloat the persisted row beyond reasonable
	 * limits.
	 *
	 * @param string $value Source string.
	 * @param int    $max   Maximum byte length (must be > 3).
	 */
	private static function truncate( string $value, int $max ): string {
		$trimmed = trim( $value );
		if ( strlen( $trimmed ) <= $max ) {
			return $trimmed;
		}
		return substr( $trimmed, 0, $max - 3 ) . '...';
	}

	/**
	 * Resolve (and cache) the option store collaborator.
	 */
	private function resolveOptionStore(): OptionStore {
		if ( $this->optionStore === null ) {
			$this->optionStore = new WpOptionStore();
		}

		return $this->optionStore;
	}

	/**
	 * Resolve (and cache) the schema version repository.
	 */
	private function resolveSchemaVersionRepository(): SchemaVersionRepository {
		if ( $this->schemaVersionRepository === null ) {
			$this->schemaVersionRepository = new SchemaVersionRepository( $this->resolveOptionStore() );
		}

		return $this->schemaVersionRepository;
	}

	/**
	 * Resolve (and cache) the schema installer.
	 */
	private function resolveSchemaInstaller(): SchemaInstaller {
		if ( $this->schemaInstaller === null ) {
			$this->schemaInstaller = new WpdbSchemaInstaller();
		}

		return $this->schemaInstaller;
	}

	/**
	 * Resolve (and cache) the table name resolver.
	 *
	 * When neither an explicit resolver nor a `$wpdb` object is
	 * available, this raises a {@see \LogicException} so a
	 * misconfiguration surfaces loudly at register() time rather
	 * than producing a resolver that would emit unprefixed DDL.
	 *
	 * @throws \LogicException When neither an explicit
	 *                         {@see TableNameResolver} nor a `$wpdb`-like
	 *                         object was supplied to the constructor.
	 */
	private function resolveTableNames(): TableNameResolver {
		if ( $this->tableNames !== null ) {
			return $this->tableNames;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit TableNameResolver '
				. 'or a $wpdb-like object to derive the table prefix from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->tableNames = PrefixedTableNameResolver::fromWpdb( $this->wpdb );

		return $this->tableNames;
	}

	/**
	 * Resolve the charset / collation clause used by every migration.
	 *
	 * Priority:
	 *
	 *   1. Explicit override supplied to the constructor.
	 *   2. `$wpdb->get_charset_collate()` when the injected object
	 *      exposes that method.
	 *   3. Empty string, which
	 *      {@see \Affilun\Infrastructure\Persistence\Migrations\AbstractCreateTableMigration::buildCreateTableSql()}
	 *      resolves to the `DEFAULT CHARSET=utf8mb4` safe default.
	 */
	private function resolveCharsetCollate(): string {
		if ( $this->charsetCollate !== null ) {
			return $this->charsetCollate;
		}

		if ( $this->wpdb !== null && method_exists( $this->wpdb, 'get_charset_collate' ) ) {
			/** @var mixed $clause */
			$clause = $this->wpdb->get_charset_collate();
			if ( is_string( $clause ) ) {
				$this->charsetCollate = $clause;
				return $this->charsetCollate;
			}
		}

		$this->charsetCollate = '';
		return $this->charsetCollate;
	}

	/**
	 * Resolve (and cache) the migration runner, populated with every
	 * initial-release migration in canonical ascending order.
	 *
	 * The runner is cached on the instance so that both the registry
	 * binding and the `affilun_activate` handler see the same object —
	 * notably, repeated `run()` calls observe the shared
	 * {@see MigrationRunner::hasRun()} state.
	 */
	private function resolveMigrationRunner(): MigrationRunner {
		if ( $this->migrationRunner !== null ) {
			return $this->migrationRunner;
		}

		$installer      = $this->resolveSchemaInstaller();
		$tableNames     = $this->resolveTableNames();
		$charsetCollate = $this->resolveCharsetCollate();

		$migrations = array(
			new CreateProductsTable( $installer, $tableNames, $charsetCollate ),
			new CreateProductMetaTable( $installer, $tableNames, $charsetCollate ),
			new CreateEmbedsTable( $installer, $tableNames, $charsetCollate ),
			new CreateEmbedItemsTable( $installer, $tableNames, $charsetCollate ),
			new CreateSyncJobsTable( $installer, $tableNames, $charsetCollate ),
			new CreateLogsTable( $installer, $tableNames, $charsetCollate ),
			new CreateAuditEventsTable( $installer, $tableNames, $charsetCollate ),
			new AddProductSearchKeywordsColumns( $installer, $tableNames, $charsetCollate ),
			new CreateProductUrlsTable( $installer, $tableNames, $charsetCollate ),
			new CreateClickTables( $installer, $tableNames, $charsetCollate ),
			new CreateTopAffiliateCandidatesTable( $installer, $tableNames, $charsetCollate ),
			new CreateAmazonDiscountProductsTable( $installer, $tableNames, $charsetCollate ),
		);

		$this->migrationRunner = ( new MigrationRunner( $this->resolveSchemaVersionRepository() ) )
			->withMigrations( $migrations );

		return $this->migrationRunner;
	}

	/**
	 * Run the pre-0.7.0 prefix data migration when a real `$wpdb` is available.
	 */
	private function runLegacyPrefixDataMigration(): void {
		$migrator = $this->resolveLegacyPrefixDataMigrator();
		if ( $migrator === null ) {
			return;
		}

		$migrator->run();
	}

	/**
	 * Resolve the legacy prefix migrator.
	 */
	private function resolveLegacyPrefixDataMigrator(): ?LegacyPrefixDataMigrator {
		if ( $this->legacyPrefixDataMigrator !== null ) {
			return $this->legacyPrefixDataMigrator;
		}
		if ( $this->wpdb === null ) {
			return null;
		}

		$this->legacyPrefixDataMigrator = new LegacyPrefixDataMigrator( $this->wpdb );
		return $this->legacyPrefixDataMigrator;
	}

	/**
	 * Resolve (and cache) the product repository.
	 *
	 * When no explicit repository was injected, the provider
	 * constructs a {@see WpdbProductRepository} against the injected
	 * `$wpdb` and the shared {@see TableNameResolver}. A missing
	 * `$wpdb` surfaces as a descriptive {@see \LogicException} so a
	 * misconfiguration (forgetting to forward the WordPress `$wpdb`
	 * to the provider) raises at register() time rather than on the
	 * first repository read.
	 *
	 * @throws \LogicException When no `$wpdb`-like object is available
	 *                         and no explicit repository was supplied.
	 */
	private function resolveProductRepository(): ProductRepository {
		if ( $this->productRepository !== null ) {
			return $this->productRepository;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit ProductRepository '
				. 'or a $wpdb-like object to construct a WpdbProductRepository from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->productRepository = new WpdbProductRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->productRepository;
	}

	/**
	 * Resolve (and cache) the Cloud top affiliate candidate repository.
	 */
	private function resolveTopAffiliateCandidateRepository(): TopAffiliateCandidateRepository {
		if ( $this->topAffiliateCandidateRepository !== null ) {
			return $this->topAffiliateCandidateRepository;
		}

		if ( $this->wpdb === null ) {
			$this->topAffiliateCandidateRepository = new InMemoryTopAffiliateCandidateRepository();
			return $this->topAffiliateCandidateRepository;
		}

		$this->topAffiliateCandidateRepository = new WpdbTopAffiliateCandidateRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->topAffiliateCandidateRepository;
	}

	/**
	 * Resolve (and cache) the Amazon discount repository.
	 */
	private function resolveAmazonDiscountRepository(): AmazonDiscountRepository {
		if ( $this->amazonDiscountRepository !== null ) {
			return $this->amazonDiscountRepository;
		}

		if ( $this->wpdb === null ) {
			$this->amazonDiscountRepository = new InMemoryAmazonDiscountRepository();
			return $this->amazonDiscountRepository;
		}

		$this->amazonDiscountRepository = new WpdbAmazonDiscountRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->amazonDiscountRepository;
	}

	/**
	 * Resolve (and cache) the embed repository.
	 *
	 * When no explicit repository was injected, the provider
	 * constructs a {@see WpdbEmbedRepository} against the injected
	 * `$wpdb` and the shared {@see TableNameResolver}. A missing
	 * `$wpdb` surfaces as a descriptive {@see \LogicException} so a
	 * misconfiguration (forgetting to forward the WordPress `$wpdb`
	 * to the provider) raises at register() time rather than on the
	 * first repository read.
	 *
	 * @throws \LogicException When no `$wpdb`-like object is available
	 *                         and no explicit repository was supplied.
	 */
	private function resolveEmbedRepository(): EmbedRepository {
		if ( $this->embedRepository !== null ) {
			return $this->embedRepository;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit EmbedRepository '
				. 'or a $wpdb-like object to construct a WpdbEmbedRepository from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->embedRepository = new WpdbEmbedRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->embedRepository;
	}

	/**
	 * Resolve (and cache) the embed-item repository.
	 *
	 * When no explicit repository was injected, the provider
	 * constructs a {@see WpdbEmbedItemRepository} against the
	 * injected `$wpdb` and the shared {@see TableNameResolver}.
	 * A missing `$wpdb` surfaces as a descriptive
	 * {@see \LogicException} so a misconfiguration (forgetting to
	 * forward the WordPress `$wpdb` to the provider) raises at
	 * register() time rather than on the first repository read.
	 *
	 * @throws \LogicException When `$wpdb` is null at resolution time
	 *                         and no explicit repository was supplied.
	 */
	private function resolveEmbedItemRepository(): EmbedItemRepository {
		if ( $this->embedItemRepository !== null ) {
			return $this->embedItemRepository;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit EmbedItemRepository '
				. 'or a $wpdb-like object to construct a WpdbEmbedItemRepository from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->embedItemRepository = new WpdbEmbedItemRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->embedItemRepository;
	}

	/**
	 * Resolve (and cache) the audit-event repository.
	 *
	 * When no explicit repository was injected, the provider
	 * constructs a {@see WpdbAuditEventRepository} against the
	 * injected `$wpdb` and the shared {@see TableNameResolver}.
	 * A missing `$wpdb` surfaces as a descriptive
	 * {@see \LogicException} so a misconfiguration (forgetting
	 * to forward the WordPress `$wpdb` to the provider) raises
	 * at register() time rather than on the first repository
	 * read.
	 *
	 * @throws \LogicException When `$wpdb` is null at resolution time
	 *                         and no explicit repository was supplied.
	 */
	private function resolveAuditEventRepository(): AuditEventRepository {
		if ( $this->auditEventRepository !== null ) {
			return $this->auditEventRepository;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit AuditEventRepository '
				. 'or a $wpdb-like object to construct a WpdbAuditEventRepository from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->auditEventRepository = new WpdbAuditEventRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->auditEventRepository;
	}

	/**
	 * Resolve (and cache) the sync-job repository.
	 *
	 * When no explicit repository was injected, the provider
	 * constructs a {@see WpdbSyncJobRepository} against the injected
	 * `$wpdb` and the shared {@see TableNameResolver}. A missing
	 * `$wpdb` surfaces as a descriptive {@see \LogicException} so a
	 * misconfiguration (forgetting to forward the WordPress `$wpdb`
	 * to the provider) raises at register() time rather than on the
	 * first repository read.
	 *
	 * @throws \LogicException When no `$wpdb`-like object is available
	 *                         and no explicit repository was supplied.
	 */
	private function resolveSyncJobRepository(): SyncJobRepository {
		if ( $this->syncJobRepository !== null ) {
			return $this->syncJobRepository;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit SyncJobRepository '
				. 'or a $wpdb-like object to construct a WpdbSyncJobRepository from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->syncJobRepository = new WpdbSyncJobRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->syncJobRepository;
	}

	/**
	 * Resolve (and cache) the log repository.
	 *
	 * When no explicit repository was injected, the provider
	 * constructs a {@see WpdbLogRepository} against the injected
	 * `$wpdb` and the shared {@see TableNameResolver}. A missing
	 * `$wpdb` surfaces as a descriptive {@see \LogicException} so a
	 * misconfiguration (forgetting to forward the WordPress `$wpdb`
	 * to the provider) raises at register() time rather than on the
	 * first repository read.
	 *
	 * @throws \LogicException When no `$wpdb`-like object is available
	 *                         and no explicit repository was supplied.
	 */
	private function resolveLogRepository(): LogRepository {
		if ( $this->logRepository !== null ) {
			return $this->logRepository;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit LogRepository '
				. 'or a $wpdb-like object to construct a WpdbLogRepository from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->logRepository = new WpdbLogRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->logRepository;
	}

	/**
	 * Resolve (and cache) the product-meta repository.
	 *
	 * When no explicit repository was injected, the provider
	 * constructs a {@see WpdbProductMetaRepository} against the
	 * injected `$wpdb` and the shared {@see TableNameResolver}. A
	 * missing `$wpdb` surfaces as a descriptive {@see \LogicException}
	 * so a misconfiguration (forgetting to forward the WordPress
	 * `$wpdb` to the provider) raises at register() time rather than
	 * on the first repository read.
	 *
	 * @throws \LogicException When no `$wpdb`-like object is available
	 *                         and no explicit repository was supplied.
	 */
	private function resolveProductMetaRepository(): ProductMetaRepository {
		if ( $this->productMetaRepository !== null ) {
			return $this->productMetaRepository;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit ProductMetaRepository '
				. 'or a $wpdb-like object to construct a WpdbProductMetaRepository from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->productMetaRepository = new WpdbProductMetaRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->productMetaRepository;
	}

	/**
	 * Resolve (and cache) the product URL repository.
	 *
	 * @throws \LogicException When no `$wpdb`-like object is available
	 *                         and no explicit repository was supplied.
	 */
	private function resolveProductUrlRepository(): ProductUrlRepository {
		if ( $this->productUrlRepository !== null ) {
			return $this->productUrlRepository;
		}

		if ( $this->wpdb === null ) {
			$this->productUrlRepository = new InMemoryProductUrlRepository();
			return $this->productUrlRepository;
		}

		$this->productUrlRepository = new WpdbProductUrlRepository(
			$this->wpdb,
			$this->resolveTableNames()
		);

		return $this->productUrlRepository;
	}

	/**
	 * Resolve click event repository.
	 */
	private function resolveClickEventRepository(): ClickEventRepository {
		if ( $this->wpdb === null ) {
			return new InMemoryClickEventRepository();
		}
		return new WpdbClickEventRepository( $this->wpdb, $this->resolveTableNames() );
	}

	/**
	 * Resolve (and cache) the transaction manager.
	 *
	 * When no explicit manager was injected, the provider constructs
	 * a {@see WpdbTransactionManager} against the injected `$wpdb`.
	 * A missing `$wpdb` surfaces as a descriptive
	 * {@see \LogicException} so a misconfiguration (forgetting to
	 * forward the WordPress `$wpdb` to the provider) raises at
	 * register() time rather than on the first transactional() call.
	 *
	 * @throws \LogicException When no `$wpdb`-like object is available
	 *                         and no explicit manager was supplied.
	 */
	private function resolveTransactionManager(): TransactionManager {
		if ( $this->transactionManager !== null ) {
			return $this->transactionManager;
		}

		if ( $this->wpdb === null ) {
			$message = 'PersistenceServiceProvider requires either an explicit TransactionManager '
				. 'or a $wpdb-like object to construct a WpdbTransactionManager from.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new \LogicException( $message );
		}

		$this->transactionManager = new WpdbTransactionManager( $this->wpdb );

		return $this->transactionManager;
	}
}
