<?php
/**
 * Persistence service provider.
 *
 * Wires the Phase 2 persistence layer into the Commerce Link Builder
 * 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 `clb_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 `clb_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 \CommerceLinkBuilder\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 `clb_activate` manually), the exception propagates
 *     verbatim so the test harness can assert on it.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use CommerceLinkBuilder\Application\Audit\AuditEventDraft;
use CommerceLinkBuilder\Application\Audit\RepositoryAuditEventWriter;
use CommerceLinkBuilder\Bootstrap\HookRegistrar;
use CommerceLinkBuilder\Bootstrap\Plugin;
use CommerceLinkBuilder\Bootstrap\ServiceProvider;
use CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry;
use CommerceLinkBuilder\Infrastructure\Clock\SystemClock;
use CommerceLinkBuilder\Infrastructure\Persistence\Migrations\CreateAuditEventsTable;
use CommerceLinkBuilder\Infrastructure\Persistence\Migrations\CreateEmbedItemsTable;
use CommerceLinkBuilder\Infrastructure\Persistence\Migrations\CreateEmbedsTable;
use CommerceLinkBuilder\Infrastructure\Persistence\Migrations\CreateLogsTable;
use CommerceLinkBuilder\Infrastructure\Persistence\Migrations\CreateProductMetaTable;
use CommerceLinkBuilder\Infrastructure\Persistence\Migrations\CreateProductsTable;
use CommerceLinkBuilder\Infrastructure\Persistence\Migrations\CreateSyncJobsTable;
use Throwable;

/**
 * Binds the persistence-layer collaborators and wires the migration
 * runner to the `clb_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 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 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;

	/**
	 * 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;

	/**
	 * 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;

	/**
	 * 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`.
	 */
	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
	) {
		$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->embedRepository       = $embedRepository;
		$this->syncJobRepository     = $syncJobRepository;
		$this->logRepository         = $logRepository;
		$this->productMetaRepository = $productMetaRepository;
		$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_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_TRANSACTION_MANAGER, $this->resolveTransactionManager() );
	}

	/**
	 * Subscribe the migration runner to the `clb_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(
			'clb_activate',
			function ( $plugin = null ): void {
				$this->handleActivate( $plugin instanceof Plugin ? $plugin : null );
			}
		);
	}

	/**
	 * 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();
	}

	/**
	 * 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 `clb_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
	 * `clb_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
	 *                            `clb_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();
		} catch ( Throwable $error ) {
			$this->emitMigrationAudit( null, $error );

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

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

			$title = function_exists( 'esc_html__' )
				? (string) esc_html__( 'Commerce Link Builder activation aborted', 'commerce-link-builder' )
				: 'Commerce Link Builder activation aborted';

			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 );
		}
	}

	/**
	 * 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
	 *     `clb_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
	 * `clb_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 `clb_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( 'clb_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' ) ) {
				// 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 );
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Defensive fallback when prepare() is unavailable; identifier resolved from canonical resolver.
				$found = $wpdb->get_var( "SHOW TABLES LIKE '" . str_replace( "'", "''", $tableName ) . "'" );
			}
		} 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 \CommerceLinkBuilder\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 `clb_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 ),
		);

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

		return $this->migrationRunner;
	}

	/**
	 * 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 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 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;
	}
}
