<?php
/**
 * Admin service provider.
 *
 * Phase-8 slice 8c-3 Bootstrap-layer service provider that wires
 * the §13 Settings admin page (and the surrounding admin-menu /
 * admin-post infrastructure) into the Commerce Link Builder
 * bootstrap pipeline. The provider:
 *
 *   1. Resolves the Phase-2 persistence
 *      {@see \CommerceLinkBuilder\Infrastructure\Persistence\OptionStore}
 *      via {@see PersistenceServiceProvider::BINDING_OPTION_STORE},
 *      raising {@see LogicException} when the binding is absent
 *      so a misordered bootstrap fails loudly here rather than
 *      at the first `admin_menu` action firing.
 *
 *   2. Builds (or accepts the test-injected override of) the
 *      §13 settings repository, the §13.10 save use case, the
 *      §13 form parser, the §13 page renderer, the §13.10 save
 *      handler, and the slice 8c-1 admin-menu registrar.
 *
 *   3. Appends an {@see AdminPageDefinition} for the §13
 *      Settings page (registered as a submenu under
 *      `options-general.php` per the WordPress core convention
 *      for plugin-settings pages) carrying a render closure
 *      that re-checks the §3 `manage_options` capability and
 *      forwards the whitelisted `clb_status` query parameter
 *      to the renderer.
 *
 *   4. Subscribes two `add_action` calls in boot():
 *
 *        - `admin_menu` → drives the bound registrar's
 *          {@see AdminMenuRegistrar::register()} so the
 *          Settings submenu lands at the canonical
 *          registration moment.
 *
 *        - `admin_post_clb_save_settings` → forwards `$_POST`
 *          to the bound {@see SaveSettingsHandler} for the
 *          §13.10 save round-trip. The handler verifies its
 *          own capability + nonce so this seam can stay thin.
 *
 * Bindings exposed (registry keys):
 *
 *   - {@see self::BINDING_ADMIN_MENU_REGISTRAR}    — slice 8c-1 registrar.
 *   - {@see self::BINDING_SETTINGS_RENDERER}       — slice 8c-2 page renderer.
 *   - {@see self::BINDING_SAVE_SETTINGS_HANDLER}   — slice 8c-3 POST handler.
 *   - {@see self::BINDING_SETTINGS_FORM_PARSER}    — slice 8c-2 form parser.
 *
 * Provider ordering — register() resolves the
 * {@see PersistenceServiceProvider::BINDING_OPTION_STORE}
 * binding, so the bootstrap pipeline in
 * `commerce-link-builder.php` MUST register
 * {@see PersistenceServiceProvider} before this provider.
 *
 * Resolution policy:
 *
 *   - The three constructor overrides are optional; omitting
 *     them causes the provider to construct the production
 *     defaults during register(). Tests inject overrides one
 *     axis at a time so the provider graph can be exercised
 *     against synthetic collaborators.
 *
 *   - Resolution is eager at register() time so a
 *     misconfiguration surfaces during
 *     {@see \CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry::registerAll()}
 *     rather than at the first `admin_menu` firing.
 *
 *   - The registrar, renderer, and save handler are also
 *     cached on the provider so the boot() action callbacks
 *     can capture them without re-resolving the registry
 *     bindings.
 *
 * See `specifications/admin-ui-spec.md` §13 (Settings page),
 * §13.10 (保存 UX), and §3 (capability gating); see
 * `specifications/security-and-compliance.md` §6.3 / §6.4 for
 * the rotating-input rule the §13.10 save flow enforces.
 *
 * @package CommerceLinkBuilder\Presentation\Admin
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin;

use CommerceLinkBuilder\Application\Audit\RepositoryAuditEventWriter;
use CommerceLinkBuilder\Application\Audit\UseCase\GetAuditEventsSummaryUseCase;
use CommerceLinkBuilder\Application\Audit\UseCase\ListAuditEventsUseCase;
use CommerceLinkBuilder\Application\Diagnostics\UseCase\GetDiagnosticsUseCase;
use CommerceLinkBuilder\Application\Logs\LogMasker;
use CommerceLinkBuilder\Application\Logs\UseCase\GetLogsSummaryUseCase;
use CommerceLinkBuilder\Application\Logs\UseCase\ListLogsUseCase;
use CommerceLinkBuilder\Application\Product\UseCase\ListProductsUseCase;
use CommerceLinkBuilder\Application\Provider\ProviderRegistry;
use CommerceLinkBuilder\Application\Settings\OptionStoreSettingsRepository;
use CommerceLinkBuilder\Application\Settings\UseCase\SaveSettingsUseCase;
use CommerceLinkBuilder\Application\SyncJobs\UseCase\ListSyncJobsUseCase;
use CommerceLinkBuilder\Bootstrap\HookRegistrar;
use CommerceLinkBuilder\Bootstrap\ServiceProvider;
use CommerceLinkBuilder\Bootstrap\ServiceProviderRegistry;
use CommerceLinkBuilder\Infrastructure\Clock\SystemClock;
use CommerceLinkBuilder\Infrastructure\Persistence\AuditEventRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\LogRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\OptionStore;
use CommerceLinkBuilder\Infrastructure\Persistence\PersistenceServiceProvider;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\SchemaVersionRepository;
use CommerceLinkBuilder\Infrastructure\Persistence\SyncJobRepository;
use CommerceLinkBuilder\Presentation\Admin\Page\AddNewProductPageRenderer;
use CommerceLinkBuilder\Presentation\Admin\Page\AuditListQueryParser;
use CommerceLinkBuilder\Presentation\Admin\Page\AuditPageRenderer;
use CommerceLinkBuilder\Presentation\Admin\Page\DiagnosticsPageRenderer;
use CommerceLinkBuilder\Presentation\Admin\Page\LogsListQueryParser;
use CommerceLinkBuilder\Presentation\Admin\Page\LogsPageRenderer;
use CommerceLinkBuilder\Presentation\Admin\Page\ProductsListPageRenderer;
use CommerceLinkBuilder\Presentation\Admin\Page\ProductsListQueryParser;
use CommerceLinkBuilder\Presentation\Admin\Page\SaveSettingsHandler;
use CommerceLinkBuilder\Presentation\Admin\Page\SettingsFormParser;
use CommerceLinkBuilder\Presentation\Admin\Page\SettingsPageRenderer;
use CommerceLinkBuilder\Presentation\Admin\Page\SyncJobsListQueryParser;
use CommerceLinkBuilder\Presentation\Admin\Page\SyncJobsPageRenderer;
use CommerceLinkBuilder\Presentation\Rest\Mapper\ProductSummaryMapper;
use CommerceLinkBuilder\Presentation\Rest\ProductsServiceProvider;
use CommerceLinkBuilder\Presentation\Rest\ProviderServiceProvider;
use LogicException;

/**
 * Binds the §13 Settings page collaborators (repository, save
 * use case, form parser, page renderer, save handler) plus the
 * slice 8c-1 {@see AdminMenuRegistrar}, then subscribes the
 * `admin_menu` and `admin_post_clb_save_settings` actions that
 * drive the §13 admin surface.
 *
 * See the file-level docblock for the bootstrap-pipeline
 * ordering requirement and the resolution policy.
 */
final class AdminServiceProvider implements ServiceProvider {

	/**
	 * Closed set of `clb_status` query-parameter literals the
	 * page-render closure forwards to the renderer.
	 *
	 * Mirrors the {@see SettingsPageRenderer} switch so an
	 * unexpected value is dropped silently rather than echoed
	 * back into the rendered HTML.
	 *
	 * @var list<string>
	 */
	private const VALID_STATUS_FLASHES = array( 'saved', 'error', 'forbidden', 'invalid_nonce' );

	/**
	 * Registry key under which the slice 8c-1
	 * {@see AdminMenuRegistrar} is bound.
	 */
	public const BINDING_ADMIN_MENU_REGISTRAR = 'clb.admin.menu_registrar';

	/**
	 * Registry key under which the slice 8c-2
	 * {@see SettingsPageRenderer} is bound.
	 */
	public const BINDING_SETTINGS_RENDERER = 'clb.admin.settings_renderer';

	/**
	 * Registry key under which the slice 8c-3
	 * {@see SaveSettingsHandler} is bound.
	 */
	public const BINDING_SAVE_SETTINGS_HANDLER = 'clb.admin.save_settings_handler';

	/**
	 * Registry key under which the slice 8c-2
	 * {@see SettingsFormParser} is bound.
	 */
	public const BINDING_SETTINGS_FORM_PARSER = 'clb.admin.settings_form_parser';

	/**
	 * Registry key under which the slice 8d
	 * {@see ProductsListPageRenderer} is bound.
	 */
	public const BINDING_PRODUCTS_LIST_PAGE_RENDERER = 'clb.admin.products_list_page_renderer';

	/**
	 * Registry key under which the slice 8d
	 * {@see ProductsListQueryParser} is bound.
	 */
	public const BINDING_PRODUCTS_LIST_QUERY_PARSER = 'clb.admin.products_list_query_parser';

	/**
	 * Registry key under which the Phase 11 slice 11f
	 * {@see DiagnosticsPageRenderer} is bound.
	 */
	public const BINDING_DIAGNOSTICS_PAGE_RENDERER = 'clb.admin.diagnostics_page_renderer';

	/**
	 * Registry key under which the Phase 11 slice 11f
	 * {@see LogsPageRenderer} is bound.
	 */
	public const BINDING_LOGS_PAGE_RENDERER = 'clb.admin.logs_page_renderer';

	/**
	 * Registry key under which the Phase 11 slice 11f
	 * {@see SyncJobsPageRenderer} is bound.
	 */
	public const BINDING_SYNC_JOBS_PAGE_RENDERER = 'clb.admin.sync_jobs_page_renderer';

	/**
	 * Registry key under which the Phase 2 KI-006 follow-up
	 * {@see AuditPageRenderer} is bound.
	 */
	public const BINDING_AUDIT_PAGE_RENDERER = 'clb.admin.audit_page_renderer';

	/**
	 * Registry key under which the strict-review fix #5 (KI-014)
	 * {@see AddNewProductPageRenderer} is bound.
	 */
	public const BINDING_ADD_NEW_PAGE_RENDERER = 'clb.admin.add_new_page_renderer';

	/**
	 * Slice 8c-1 admin-menu registrar override.
	 */
	private ?AdminMenuRegistrar $menuRegistrarOverride;

	/**
	 * Slice 8c-2 settings-page renderer override.
	 */
	private ?SettingsPageRenderer $rendererOverride;

	/**
	 * Slice 8c-3 save-settings handler override.
	 */
	private ?SaveSettingsHandler $saveHandlerOverride;

	/**
	 * Slice 8d products-list-page renderer override.
	 */
	private ?ProductsListPageRenderer $productsListRendererOverride;

	/**
	 * Slice 8d products-list-page query parser override.
	 */
	private ?ProductsListQueryParser $productsListQueryParserOverride;

	/**
	 * Phase 11 slice 11f diagnostics-page renderer override.
	 */
	private ?DiagnosticsPageRenderer $diagnosticsRendererOverride;

	/**
	 * Phase 11 slice 11f logs-page renderer override.
	 */
	private ?LogsPageRenderer $logsRendererOverride;

	/**
	 * Phase 11 slice 11f sync-jobs-page renderer override.
	 */
	private ?SyncJobsPageRenderer $syncJobsRendererOverride;

	/**
	 * Phase 2 KI-006 follow-up audit-page renderer override.
	 */
	private ?AuditPageRenderer $auditRendererOverride;

	/**
	 * Strict-review fix #5 (KI-014) Add New page renderer override.
	 */
	private ?AddNewProductPageRenderer $addNewRendererOverride;

	/**
	 * Cached {@see AdminMenuRegistrar} bound into the registry.
	 */
	private ?AdminMenuRegistrar $resolvedMenuRegistrar = null;

	/**
	 * Cached {@see SettingsPageRenderer} bound into the registry.
	 */
	private ?SettingsPageRenderer $resolvedRenderer = null;

	/**
	 * Cached {@see SaveSettingsHandler} bound into the registry.
	 */
	private ?SaveSettingsHandler $resolvedSaveHandler = null;

	/**
	 * Cached {@see ProductsListPageRenderer} bound into the
	 * registry.
	 */
	private ?ProductsListPageRenderer $resolvedProductsListRenderer = null;

	/**
	 * Cached {@see ProductsListQueryParser} bound into the
	 * registry.
	 */
	private ?ProductsListQueryParser $resolvedProductsListQueryParser = null;

	/**
	 * Cached {@see DiagnosticsPageRenderer} bound into the registry.
	 */
	private ?DiagnosticsPageRenderer $resolvedDiagnosticsRenderer = null;

	/**
	 * Cached {@see LogsPageRenderer} bound into the registry.
	 */
	private ?LogsPageRenderer $resolvedLogsRenderer = null;

	/**
	 * Cached {@see SyncJobsPageRenderer} bound into the registry.
	 */
	private ?SyncJobsPageRenderer $resolvedSyncJobsRenderer = null;

	/**
	 * Cached {@see AuditPageRenderer} bound into the registry.
	 */
	private ?AuditPageRenderer $resolvedAuditRenderer = null;

	/**
	 * Cached {@see AddNewProductPageRenderer} bound into the
	 * registry (strict-review fix #5, KI-014). Captured by the
	 * page-render closure for the §7 Add New admin page.
	 */
	private ?AddNewProductPageRenderer $resolvedAddNewRenderer = null;

	/**
	 * Cached {@see LogsListQueryParser} (built inline during
	 * register()) so the page-render closure for the Logs page can
	 * route `$_GET` through it without re-resolving.
	 */
	private ?LogsListQueryParser $resolvedLogsListQueryParser = null;

	/**
	 * Cached {@see SyncJobsListQueryParser} (built inline during
	 * register()) so the page-render closure for the Sync Jobs page
	 * can route `$_GET` through it without re-resolving.
	 */
	private ?SyncJobsListQueryParser $resolvedSyncJobsListQueryParser = null;

	/**
	 * Cached {@see AuditListQueryParser} (built inline during
	 * register()) so the page-render closure for the Audit page
	 * can route `$_GET` through it without re-resolving.
	 */
	private ?AuditListQueryParser $resolvedAuditListQueryParser = null;

	/**
	 * Construct an admin service provider with optional
	 * overrides for each collaborator.
	 *
	 * Empty / no-arg construction is the production default;
	 * tests pass overrides one axis at a time so the provider
	 * graph can be exercised against synthetic collaborators
	 * without standing up the persistence stack.
	 *
	 * @param AdminMenuRegistrar|null        $menuRegistrarOverride           Slice 8c-1 admin-menu registrar override.
	 * @param SettingsPageRenderer|null      $rendererOverride                Slice 8c-2 page renderer override.
	 * @param SaveSettingsHandler|null       $saveHandlerOverride             Slice 8c-3 save handler override.
	 * @param ProductsListPageRenderer|null  $productsListRendererOverride    Slice 8d products-list renderer override.
	 * @param ProductsListQueryParser|null   $productsListQueryParserOverride Slice 8d query parser override.
	 * @param DiagnosticsPageRenderer|null   $diagnosticsRendererOverride     Phase 11 11f diagnostics renderer override.
	 * @param LogsPageRenderer|null          $logsRendererOverride            Phase 11 11f logs renderer override.
	 * @param SyncJobsPageRenderer|null      $syncJobsRendererOverride        Phase 11 11f sync-jobs renderer override.
	 * @param AuditPageRenderer|null         $auditRendererOverride           Phase 2 KI-006 follow-up audit
	 *                                                                        admin-page renderer override.
	 * @param AddNewProductPageRenderer|null $addNewRendererOverride         Strict-review fix #5 (KI-014)
	 *                                                                       Add New admin-page renderer override.
	 */
	public function __construct(
		?AdminMenuRegistrar $menuRegistrarOverride = null,
		?SettingsPageRenderer $rendererOverride = null,
		?SaveSettingsHandler $saveHandlerOverride = null,
		?ProductsListPageRenderer $productsListRendererOverride = null,
		?ProductsListQueryParser $productsListQueryParserOverride = null,
		?DiagnosticsPageRenderer $diagnosticsRendererOverride = null,
		?LogsPageRenderer $logsRendererOverride = null,
		?SyncJobsPageRenderer $syncJobsRendererOverride = null,
		?AuditPageRenderer $auditRendererOverride = null,
		?AddNewProductPageRenderer $addNewRendererOverride = null
	) {
		$this->menuRegistrarOverride           = $menuRegistrarOverride;
		$this->rendererOverride                = $rendererOverride;
		$this->saveHandlerOverride             = $saveHandlerOverride;
		$this->productsListRendererOverride    = $productsListRendererOverride;
		$this->productsListQueryParserOverride = $productsListQueryParserOverride;
		$this->diagnosticsRendererOverride     = $diagnosticsRendererOverride;
		$this->logsRendererOverride            = $logsRendererOverride;
		$this->syncJobsRendererOverride        = $syncJobsRendererOverride;
		$this->auditRendererOverride           = $auditRendererOverride;
		$this->addNewRendererOverride          = $addNewRendererOverride;
	}

	/**
	 * Bind the slice 8c-1 / 8c-2 / 8c-3 admin collaborators into
	 * the registry, then append the §13 Settings page
	 * {@see AdminPageDefinition} to the bound registrar.
	 *
	 * Resolution order:
	 *
	 *   1. Resolve {@see OptionStore} via
	 *      {@see PersistenceServiceProvider::BINDING_OPTION_STORE}.
	 *      Missing binding raises {@see LogicException}.
	 *
	 *   2. Build the §13 repository, the §13.10 save use case,
	 *      and the §13 form parser.
	 *
	 *   3. Build (or accept the override of) the §13 page
	 *      renderer and the §13.10 save handler.
	 *
	 *   4. Build (or accept the override of) the slice 8c-1
	 *      admin-menu registrar.
	 *
	 *   5. Append the §13 Settings page definition to the
	 *      registrar.
	 *
	 *   6. Bind all four under the `BINDING_*` keys and cache
	 *      the registrar / renderer / save handler so the boot()
	 *      action callbacks can capture them.
	 *
	 * @param ServiceProviderRegistry $registry Registry driving
	 *                                          this bootstrap pass.
	 *
	 * @throws LogicException When the persistence provider has
	 *                        not registered before this one.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		$optionStore       = $this->resolveOptionStore( $registry );
		$productRepository = $this->resolveProductRepository( $registry );

		$repository = new OptionStoreSettingsRepository( $optionStore );
		$useCase    = new SaveSettingsUseCase( $repository );
		$parser     = new SettingsFormParser();

		$renderer      = $this->rendererOverride ?? new SettingsPageRenderer( $repository );
		$auditWriter   = $this->resolveOptionalAuditEventWriter( $registry );
		$saveHandler   = $this->saveHandlerOverride ?? new SaveSettingsHandler(
			$repository,
			$useCase,
			$parser,
			null,
			null,
			null,
			null,
			$auditWriter
		);
		$menuRegistrar = $this->menuRegistrarOverride ?? new AdminMenuRegistrar();

		$listUseCase = $this->resolveListProductsUseCase( $registry, $productRepository );

		$productsListRenderer    = $this->productsListRendererOverride
			?? new ProductsListPageRenderer( $listUseCase, new ProductSummaryMapper() );
		$productsListQueryParser = $this->productsListQueryParserOverride
			?? new ProductsListQueryParser();

		$logRepository           = $this->resolveLogRepository( $registry );
		$syncJobRepository       = $this->resolveSyncJobRepository( $registry );
		$schemaVersionRepository = $this->resolveSchemaVersionRepository( $registry );

		$logMasker             = new LogMasker();
		$listLogsUseCase       = new ListLogsUseCase( $logRepository, $logMasker );
		$getLogsSummaryUseCase = new GetLogsSummaryUseCase( $logRepository, $logMasker );
		$listSyncJobsUseCase   = new ListSyncJobsUseCase( $syncJobRepository );
		// Phase 12 slice 12c: the providers map is resolved from the
		// centralized ProviderRegistry binding published by
		// ProviderServiceProvider. When the binding is missing —
		// typically in tests that pre-seed the admin collaborators
		// without standing up the provider service provider — the
		// resolver falls back to an empty registry so the admin
		// diagnostics page renders an empty `providers` branch
		// rather than failing.
		$diagnosticsUseCase = new GetDiagnosticsUseCase(
			$schemaVersionRepository,
			$syncJobRepository,
			$this->resolveProviderRegistry( $registry )->all(),
			null,
			new SystemClock()
		);

		$diagnosticsRenderer = $this->diagnosticsRendererOverride
			?? new DiagnosticsPageRenderer( $diagnosticsUseCase );
		$logsRenderer        = $this->logsRendererOverride
			?? new LogsPageRenderer( $listLogsUseCase, $getLogsSummaryUseCase );
		$syncJobsRenderer    = $this->syncJobsRendererOverride
			?? new SyncJobsPageRenderer( $listSyncJobsUseCase );

		$this->resolvedMenuRegistrar           = $menuRegistrar;
		$this->resolvedRenderer                = $renderer;
		$this->resolvedSaveHandler             = $saveHandler;
		$this->resolvedProductsListRenderer    = $productsListRenderer;
		$this->resolvedProductsListQueryParser = $productsListQueryParser;
		$this->resolvedDiagnosticsRenderer     = $diagnosticsRenderer;
		$this->resolvedLogsRenderer            = $logsRenderer;
		$this->resolvedSyncJobsRenderer        = $syncJobsRenderer;
		$this->resolvedLogsListQueryParser     = new LogsListQueryParser();
		$this->resolvedSyncJobsListQueryParser = new SyncJobsListQueryParser();
		$this->resolvedAddNewRenderer          = $this->addNewRendererOverride ?? new AddNewProductPageRenderer();

		$menuRegistrar->add( $this->buildSettingsPageDefinition() );
		$menuRegistrar->add( $this->buildProductsListPageDefinition() );
		$menuRegistrar->add( $this->buildProductsListSubmenuOverrideDefinition() );
		$menuRegistrar->add( $this->buildAddNewProductPageDefinition() );
		$menuRegistrar->add( $this->buildDiagnosticsPageDefinition() );
		$menuRegistrar->add( $this->buildLogsPageDefinition() );
		$menuRegistrar->add( $this->buildSyncJobsPageDefinition() );

		$auditRenderer = $this->resolveAuditRenderer( $registry );
		if ( $auditRenderer !== null ) {
			$this->resolvedAuditRenderer        = $auditRenderer;
			$this->resolvedAuditListQueryParser = new AuditListQueryParser();
			$menuRegistrar->add( $this->buildAuditPageDefinition() );
		}

		$registry->bind( self::BINDING_ADMIN_MENU_REGISTRAR, $menuRegistrar );
		$registry->bind( self::BINDING_SETTINGS_RENDERER, $renderer );
		$registry->bind( self::BINDING_SAVE_SETTINGS_HANDLER, $saveHandler );
		$registry->bind( self::BINDING_SETTINGS_FORM_PARSER, $parser );
		$registry->bind( self::BINDING_PRODUCTS_LIST_PAGE_RENDERER, $productsListRenderer );
		$registry->bind( self::BINDING_PRODUCTS_LIST_QUERY_PARSER, $productsListQueryParser );
		$registry->bind( self::BINDING_DIAGNOSTICS_PAGE_RENDERER, $diagnosticsRenderer );
		$registry->bind( self::BINDING_LOGS_PAGE_RENDERER, $logsRenderer );
		$registry->bind( self::BINDING_SYNC_JOBS_PAGE_RENDERER, $syncJobsRenderer );
		$registry->bind( self::BINDING_ADD_NEW_PAGE_RENDERER, $this->resolvedAddNewRenderer );
		if ( $auditRenderer !== null ) {
			$registry->bind( self::BINDING_AUDIT_PAGE_RENDERER, $auditRenderer );
		}
	}

	/**
	 * Resolve the Phase 2 KI-006 follow-up {@see AuditPageRenderer}.
	 *
	 * Honours the constructor override first so tests that wire a
	 * synthetic renderer keep working without binding the
	 * persistence-layer audit-event repository. When no override
	 * is supplied, soft-resolves
	 * {@see PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY}
	 * via the same skip-on-missing pattern as
	 * {@see self::resolveOptionalAuditEventWriter()}; on miss
	 * returns null so the audit page wiring is skipped entirely.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveAuditRenderer(
		ServiceProviderRegistry $registry
	): ?AuditPageRenderer {
		if ( $this->auditRendererOverride !== null ) {
			return $this->auditRendererOverride;
		}

		$key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

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

		$listAuditEventsUseCase       = new ListAuditEventsUseCase( $auditEventRepo );
		$getAuditEventsSummaryUseCase = new GetAuditEventsSummaryUseCase( $auditEventRepo );

		return new AuditPageRenderer( $listAuditEventsUseCase, $getAuditEventsSummaryUseCase );
	}

	/**
	 * Subscribe the `admin_menu` and
	 * `admin_post_clb_save_settings` action callbacks.
	 *
	 * Both callbacks are public methods on this provider (rather
	 * than closures) so the {@see HookRegistrar}'s introspection
	 * surface carries stable, named callables that tests and
	 * diagnostics can identify without unwrapping a closure.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		$hooks->addAction( 'admin_menu', array( $this, 'onAdminMenu' ) );
		$hooks->addAction( 'admin_post_clb_save_settings', array( $this, 'onSaveSettingsPost' ) );
	}

	/**
	 * `admin_menu` action callback.
	 *
	 * Drives {@see AdminMenuRegistrar::register()} so every
	 * {@see AdminPageDefinition} appended during register() is
	 * forwarded to WordPress at the canonical registration
	 * moment.
	 */
	public function onAdminMenu(): void {
		if ( $this->resolvedMenuRegistrar === null ) {
			return;
		}

		$this->resolvedMenuRegistrar->register();
	}

	/**
	 * `admin_post_clb_save_settings` action callback.
	 *
	 * Forwards `$_POST` to the bound {@see SaveSettingsHandler}.
	 * The handler verifies its own capability + nonce, applies
	 * the §6.3 rotating-input rule via the
	 * {@see SaveSettingsUseCase}, and redirects with a
	 * `clb_status` flash literal — so this seam can stay thin.
	 */
	public function onSaveSettingsPost(): void {
		if ( $this->resolvedSaveHandler === null ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The save handler verifies its own nonce via SettingsFormToken::NONCE_FIELD; sanitization happens inside the parser.
		$postData = $_POST;
		$this->resolvedSaveHandler->handle( is_array( $postData ) ? $postData : array() );
	}

	/**
	 * Build the §13 Settings page {@see AdminPageDefinition}.
	 *
	 * The render closure re-checks the §3 `manage_options`
	 * capability via {@see AdminCapability::CAP_FALLBACK_MANAGE}
	 * (the slice 8c-2 renderer is admin-only-by-contract; this
	 * is the canonical capability seam for the page-render
	 * dispatch path) and forwards the whitelisted `clb_status`
	 * query parameter to the cached renderer.
	 */
	private function buildSettingsPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'options-general.php',
			'Commerce Link Builder 設定',
			'Commerce Link Builder',
			AdminCapability::CAP_FALLBACK_MANAGE,
			'clb-settings',
			function (): void {
				$this->renderSettingsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §6 Products list page {@see AdminPageDefinition}.
	 *
	 * Phase-8 minimum registers Products as a top-level admin
	 * menu (matching `specifications/admin-ui-spec.md` §2.1
	 * "[CLB] under WordPress admin menu — Dashboard / Products /
	 * ..."); a future slice may consolidate Settings under the
	 * same top-level CLB menu.
	 *
	 * The render closure re-checks the §3 editor-tier capability
	 * via {@see AdminCapability::CAP_VIEW_PRODUCTS} and forwards
	 * `$_GET` (already unslashed) to
	 * {@see ProductsListQueryParser::parse()} to construct a
	 * {@see ProductListQuery}, then dispatches to the cached
	 * {@see ProductsListPageRenderer}.
	 */
	private function buildProductsListPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'',
			'Commerce Link Builder 商品一覧',
			'Commerce Link Builder',
			AdminCapability::CAP_VIEW_PRODUCTS,
			'clb-products',
			function (): void {
				$this->renderProductsListPage();
			},
			'dashicons-cart',
			30,
			null
		);
	}

	/**
	 * Override WordPress's auto-generated first submenu entry.
	 *
	 * `add_menu_page()` automatically duplicates the top-level
	 * entry as a first submenu using the menu_title (`Commerce
	 * Link Builder`). Registering an explicit `add_submenu_page()`
	 * with the same parent + menu slug as the top-level page
	 * replaces that auto-generated row with the supplied menu
	 * title (`商品一覧`), eliminating the visible duplicate while
	 * still routing to the same render closure.
	 */
	private function buildProductsListSubmenuOverrideDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'clb-products',
			'Commerce Link Builder 商品一覧',
			'商品一覧',
			AdminCapability::CAP_VIEW_PRODUCTS,
			'clb-products',
			// No-op render: the top-level definition above already
			// registers the products-list render closure against the
			// same `toplevel_page_clb-products` action hook. Wiring
			// the same closure here would make WordPress fire the
			// render callback twice and emit duplicate page HTML.
			static function (): void {},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §7 Add New admin page {@see AdminPageDefinition}
	 * (strict-review fix #5, KI-014).
	 *
	 * Registered as a submenu under the existing top-level
	 * `clb-products` CLB menu so the editor-tier admin pages
	 * (Products list + Add New) live side-by-side, mirroring the
	 * WordPress core convention for `edit.php` + `post-new.php`.
	 * Capability gating uses the editor-tier
	 * {@see AdminCapability::CAP_VIEW_PRODUCTS} (`edit_posts`).
	 */
	private function buildAddNewProductPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'clb-products',
			'Commerce Link Builder 商品を追加',
			'商品を追加',
			AdminCapability::CAP_VIEW_PRODUCTS,
			'clb-add-new',
			function (): void {
				$this->renderAddNewProductPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §13 Diagnostics admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under the existing top-level
	 * `clb-products` CLB menu so all Phase-11 admin pages share one
	 * top-level entry. Capability gating uses
	 * {@see AdminCapability::CAP_FALLBACK_MANAGE} (`manage_options`).
	 */
	private function buildDiagnosticsPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'clb-products',
			'Commerce Link Builder 診断情報',
			'診断情報',
			AdminCapability::CAP_VIEW_DIAGNOSTICS,
			'clb-diagnostics',
			function (): void {
				$this->renderDiagnosticsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §6 Logs admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under `clb-products`. Capability
	 * gating uses {@see AdminCapability::CAP_FALLBACK_MANAGE}
	 * (`manage_options`) since logs may surface admin-tier
	 * diagnostics context.
	 */
	private function buildLogsPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'clb-products',
			'Commerce Link Builder ログ',
			'ログ',
			AdminCapability::CAP_VIEW_LOGS,
			'clb-logs',
			function (): void {
				$this->renderLogsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §6 Sync Jobs admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under `clb-products`. Capability
	 * gating uses {@see AdminCapability::CAP_FALLBACK_MANAGE}
	 * (`manage_options`).
	 */
	private function buildSyncJobsPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'clb-products',
			'Commerce Link Builder 同期ジョブ',
			'同期ジョブ',
			AdminCapability::CAP_RUN_SYNC,
			'clb-sync-jobs',
			function (): void {
				$this->renderSyncJobsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §10 Audit Events admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under `clb-products`. Capability
	 * gating uses {@see AdminCapability::CAP_VIEW_DIAGNOSTICS}
	 * (with {@see AdminCapability::CAP_FALLBACK_MANAGE} as the
	 * OR-list fallback through {@see AdminCapability::PAGE_TOKEN_AUDIT}).
	 */
	private function buildAuditPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'clb-products',
			'Commerce Link Builder 監査イベント',
			'監査イベント',
			AdminCapability::CAP_VIEW_DIAGNOSTICS,
			'clb-audit',
			function (): void {
				$this->renderAuditPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Page-render dispatch for the §13 Diagnostics page.
	 */
	private function renderDiagnosticsPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_DIAGNOSTICS ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'Commerce Link Builder',
					array( 'response' => 403 )
				);
			}
			return;
		}

		if ( $this->resolvedDiagnosticsRenderer === null ) {
			return;
		}

		$this->resolvedDiagnosticsRenderer->render();
	}

	/**
	 * Page-render dispatch for the §6 Logs page.
	 */
	private function renderLogsPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_LOGS ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'Commerce Link Builder',
					array( 'response' => 403 )
				);
			}
			return;
		}

		if (
			$this->resolvedLogsRenderer === null
			|| $this->resolvedLogsListQueryParser === null
		) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only listing; no state mutation. Per-axis sanitization happens inside LogsListQueryParser.
		$rawGet    = is_array( $_GET ) ? $_GET : array();
		$unslashed = function_exists( 'wp_unslash' )
			? (array) wp_unslash( $rawGet )
			: $rawGet;

		$query = $this->resolvedLogsListQueryParser->parse( $unslashed );
		$this->resolvedLogsRenderer->render( $query );
	}

	/**
	 * Page-render dispatch for the §6 Sync Jobs page.
	 */
	private function renderSyncJobsPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SYNC_JOBS ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'Commerce Link Builder',
					array( 'response' => 403 )
				);
			}
			return;
		}

		if (
			$this->resolvedSyncJobsRenderer === null
			|| $this->resolvedSyncJobsListQueryParser === null
		) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only listing; no state mutation. Per-axis sanitization happens inside SyncJobsListQueryParser.
		$rawGet    = is_array( $_GET ) ? $_GET : array();
		$unslashed = function_exists( 'wp_unslash' )
			? (array) wp_unslash( $rawGet )
			: $rawGet;

		$query = $this->resolvedSyncJobsListQueryParser->parse( $unslashed );
		$this->resolvedSyncJobsRenderer->render( $query );
	}

	/**
	 * Page-render dispatch for the §10 Audit Events page.
	 */
	private function renderAuditPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_AUDIT ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'Commerce Link Builder',
					array( 'response' => 403 )
				);
			}
			return;
		}

		if (
			$this->resolvedAuditRenderer === null
			|| $this->resolvedAuditListQueryParser === null
		) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only listing; no state mutation. Per-axis sanitization happens inside AuditListQueryParser.
		$rawGet    = is_array( $_GET ) ? $_GET : array();
		$unslashed = function_exists( 'wp_unslash' )
			? (array) wp_unslash( $rawGet )
			: $rawGet;

		$query = $this->resolvedAuditListQueryParser->parse( $unslashed );
		$this->resolvedAuditRenderer->render( $query );
	}

	/**
	 * Page-render dispatch for the §6 Products list page.
	 *
	 * Re-checks the §3 editor-tier capability and routes the
	 * `$_GET` array (unslashed via `wp_unslash()` when available)
	 * through the cached {@see ProductsListQueryParser} so a
	 * tampered URL falls back to {@see ProductListQuery::defaults()}
	 * rather than reaching the renderer with a partially-validated
	 * query.
	 */
	private function renderProductsListPage(): void {
		if (
			! function_exists( 'current_user_can' )
			|| ! current_user_can( AdminCapability::CAP_VIEW_PRODUCTS )
		) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'Commerce Link Builder',
					array( 'response' => 403 )
				);
			}
			return;
		}

		if (
			$this->resolvedProductsListRenderer === null
			|| $this->resolvedProductsListQueryParser === null
		) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only listing; no state mutation. Per-axis sanitization happens inside ProductsListQueryParser.
		$rawGet    = is_array( $_GET ) ? $_GET : array();
		$unslashed = function_exists( 'wp_unslash' )
			? (array) wp_unslash( $rawGet )
			: $rawGet;

		$query = $this->resolvedProductsListQueryParser->parse( $unslashed );
		$this->resolvedProductsListRenderer->render( $query );
	}

	/**
	 * Page-render dispatch for the §7 Add New admin page
	 * (strict-review fix #5, KI-014).
	 *
	 * Re-checks the editor-tier capability via
	 * {@see AdminCapability::PAGE_TOKEN_PRODUCTS_NEW}'s OR-list
	 * (`edit_posts` primary; no fallback configured because the
	 * primary already maps to a built-in WordPress capability)
	 * and forwards the whitelisted `clb_status` query parameter
	 * to the cached renderer. Unknown status values are dropped
	 * silently rather than echoed back into the page.
	 */
	private function renderAddNewProductPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_NEW ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'Commerce Link Builder',
					array( 'response' => 403 )
				);
			}
			return;
		}

		if ( $this->resolvedAddNewRenderer === null ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display path; no state mutation. The save action runs through the REST POST /clb/v1/products endpoint which verifies its own permission_callback + nonce.
		if ( isset( $_GET['clb_status'] ) && is_scalar( $_GET['clb_status'] ) ) {
			$unslashed = function_exists( 'wp_unslash' )
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below via sanitize_key() / preg_replace.
				? (string) wp_unslash( (string) $_GET['clb_status'] )
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test-mode fallback.
				: (string) $_GET['clb_status'];
			$rawStatus = function_exists( 'sanitize_key' )
				? (string) sanitize_key( $unslashed )
				: (string) preg_replace( '/[^a-z0-9_\-]/i', '', $unslashed );
		} else {
			$rawStatus = '';
		}

		$flash = in_array( $rawStatus, AddNewProductPageRenderer::VALID_STATUS_FLASHES, true ) ? $rawStatus : null;
		$this->resolvedAddNewRenderer->render( $flash );
	}

	/**
	 * Page-render dispatch.
	 *
	 * Re-checks the §3 admin-tier capability and forwards the
	 * whitelisted `clb_status` query parameter to the cached
	 * renderer. Unknown status values are dropped silently
	 * rather than echoed back into the page so a malicious
	 * `?clb_status=...` value cannot reach the renderer.
	 */
	private function renderSettingsPage(): void {
		if (
			! function_exists( 'current_user_can' )
			|| ! current_user_can( AdminCapability::CAP_FALLBACK_MANAGE )
		) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'Commerce Link Builder',
					array( 'response' => 403 )
				);
			}
			return;
		}

		if ( $this->resolvedRenderer === null ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display path; no state mutation. The save action lives at admin-post.php?action=clb_save_settings and verifies its own nonce.
		if ( isset( $_GET['clb_status'] ) && is_scalar( $_GET['clb_status'] ) ) {
			$unslashed = function_exists( 'wp_unslash' )
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below via sanitize_key()/preg_replace; nonce verification justification as above.
				? (string) wp_unslash( (string) $_GET['clb_status'] )
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test-mode fallback when wp_unslash() is unavailable; the value is sanitized below via sanitize_key()/preg_replace.
				: (string) $_GET['clb_status'];
			$rawStatus = function_exists( 'sanitize_key' )
				? (string) sanitize_key( $unslashed )
				: (string) preg_replace( '/[^a-z0-9_\-]/i', '', $unslashed );
		} else {
			$rawStatus = '';
		}

		$flash = in_array( $rawStatus, self::VALID_STATUS_FLASHES, true ) ? $rawStatus : null;

		$this->resolvedRenderer->render( $flash );
	}

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

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

		return $optionStore;
	}

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

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

		return $repository;
	}

	/**
	 * Resolve the §8.2 {@see ListProductsUseCase} from the
	 * registry, falling back to a default-constructed instance
	 * wrapping the supplied repository when the
	 * {@see ProductsServiceProvider::BINDING_LIST_PRODUCTS_USE_CASE}
	 * binding is absent.
	 *
	 * Preferring the bound use case when present keeps the
	 * Phase-6 ProductsServiceProvider and the Phase-8 admin
	 * surface loosely coupled; the default-construction branch
	 * keeps the admin provider operable in test harnesses that
	 * exercise the admin graph without booting the REST stack.
	 *
	 * @param ServiceProviderRegistry $registry          Bootstrap registry.
	 * @param ProductRepository       $productRepository Repository for default construction.
	 */
	private function resolveListProductsUseCase(
		ServiceProviderRegistry $registry,
		ProductRepository $productRepository
	): ListProductsUseCase {
		$key = ProductsServiceProvider::BINDING_LIST_PRODUCTS_USE_CASE;
		if ( $registry->has( $key ) ) {
			$bound = $registry->get( $key );
			if ( $bound instanceof ListProductsUseCase ) {
				return $bound;
			}
		}

		return new ListProductsUseCase( $productRepository );
	}

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

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

		return $repo;
	}

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

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

		return $repo;
	}

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

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

		return $repo;
	}

	/**
	 * Resolve the {@see ProviderRegistry} from the registry.
	 *
	 * Phase 12 slice 12c centralizes the §12.1 providers map under
	 * {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}.
	 * When the binding is missing — typically in tests that
	 * pre-seed the admin collaborators without standing up the
	 * provider service provider — this method returns a fresh
	 * empty {@see ProviderRegistry} so the admin diagnostics page
	 * renders an empty `providers` branch rather than failing
	 * loudly.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding exists but is not a
	 *                        {@see ProviderRegistry}.
	 */
	private function resolveProviderRegistry( ServiceProviderRegistry $registry ): ProviderRegistry {
		$key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY;
		if ( ! $registry->has( $key ) ) {
			return new ProviderRegistry( array() );
		}

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

		return $value;
	}

	/**
	 * Re-check page-render authorization through the §3.3
	 * {@see AdminCapability} OR-list (Phase 2 KI-008).
	 *
	 * Wraps `current_user_can()` in a closure compatible with
	 * {@see AdminCapability::isAuthorized()} so the dispatch
	 * path accepts either the dedicated `clb_*` capability OR
	 * the `manage_options` fallback. When `current_user_can()`
	 * is unavailable (pure-unit test path without WordPress
	 * loaded), the probe denies, matching the legacy
	 * fail-closed behavior.
	 *
	 * @param string $pageToken Stable page token from
	 *                          {@see AdminCapability}.
	 */
	private function isAuthorizedForPage( string $pageToken ): bool {
		$probe = static function ( string $cap ): bool {
			return function_exists( 'current_user_can' )
				&& (bool) current_user_can( $cap );
		};

		return AdminCapability::isAuthorized( $probe, $pageToken );
	}

	/**
	 * Soft-resolve the {@see AuditEventRepository} from the
	 * registry and wrap it in a {@see RepositoryAuditEventWriter}
	 * so the §13 admin Settings save can emit §10.1
	 * `configuration_change` audit rows.
	 *
	 * Returns null when the binding is missing or carries an
	 * unexpected value — pure-unit harnesses that don't bind the
	 * audit repository (the test seed `buildPreparedRegistry()`
	 * predates the audit-writer wiring) keep working without
	 * modification, and the {@see SaveSettingsHandler} stays
	 * silent on the audit channel rather than failing loudly.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalAuditEventWriter(
		ServiceProviderRegistry $registry
	): ?RepositoryAuditEventWriter {
		$key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

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

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