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

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

/**
 * 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 affilun
 * bootstrap pipeline. The provider:
 *
 *   1. Resolves the Phase-2 persistence
 *      {@see \Affilun\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 `affilun_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_affilun_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
 * `affilun.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 \Affilun\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 Affilun\Presentation\Admin
 */
use Affilun\Application\Audit\RepositoryAuditEventWriter;
use Affilun\Application\Cache\CacheServiceProvider;
use Affilun\Application\Cache\RenderCache;
use Affilun\Application\Cloud\CloudAiPostScanRunner;
use Affilun\Application\Cloud\CloudCampaignFeedClient;
use Affilun\Application\Cloud\CloudProductArticleDraftClient;
use Affilun\Application\Audit\UseCase\GetAuditEventsSummaryUseCase;
use Affilun\Application\Audit\UseCase\ListAuditEventsUseCase;
use Affilun\Application\Diagnostics\UseCase\GetDiagnosticsUseCase;
use Affilun\Application\Logs\LogMasker;
use Affilun\Application\Logs\UseCase\GetLogsSummaryUseCase;
use Affilun\Application\Logs\UseCase\ListLogsUseCase;
use Affilun\Application\LinkHealth\LinkHealthStatusClassifier;
use Affilun\Application\LinkHealth\ProductLinkHealthStatusStore;
use Affilun\Application\Migration\ExternalAffiliateProductImportUseCase;
use Affilun\Application\Product\Csv\ProductCsvExporter;
use Affilun\Application\Product\Csv\ProductCsvImportUseCase;
use Affilun\Application\Product\ProductManualTaxonomy;
use Affilun\Application\Product\UseCase\BulkUpdateProductsUseCase;
use Affilun\Application\Product\UseCase\ListProductsUseCase;
use Affilun\Application\Product\UseCase\SaveProductUseCase;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Render\ProductCardRenderer;
use Affilun\Application\Render\RenderServiceProvider;
use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory;
use Affilun\Application\Settings\CloudSettings;
use Affilun\Application\Settings\OptionStoreSettingsRepository;
use Affilun\Application\Settings\PluginSettings;
use Affilun\Application\Settings\UseCase\SaveSettingsUseCase;
use Affilun\Application\Affiliate\OptionStoreAffiliateRoutingRepository;
use Affilun\Application\Sync\SyncDispatcher;
use Affilun\Application\SyncJobs\UseCase\ListSyncJobsUseCase;
use Affilun\Bootstrap\AmazonDiscountServiceProvider;
use Affilun\Bootstrap\HookRegistrar;
use Affilun\Bootstrap\ServiceProvider;
use Affilun\Bootstrap\ServiceProviderRegistry;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Persistence\AuditEventRepository;
use Affilun\Infrastructure\Persistence\ClickEventRepository;
use Affilun\Infrastructure\Persistence\LogRepository;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Infrastructure\Persistence\ProductListQuery;
use Affilun\Infrastructure\Persistence\ProductMetaRepository;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\SchemaVersionRepository;
use Affilun\Infrastructure\Persistence\SyncJobRepository;
use Affilun\Infrastructure\Persistence\TopAffiliateCandidateRepository;
use Affilun\Presentation\Admin\Page\AddNewProductPageRenderer;
use Affilun\Presentation\Admin\Page\AmazonDiscountShortcodeCardRenderer;
use Affilun\Presentation\Admin\Page\AuditListQueryParser;
use Affilun\Presentation\Admin\Page\AuditPageRenderer;
use Affilun\Presentation\Admin\Page\ClicksDashboardPageRenderer;
use Affilun\Presentation\Admin\Page\CloudToolsPageRenderer;
use Affilun\Presentation\Admin\Page\DiagnosticsPageRenderer;
use Affilun\Presentation\Admin\Page\LogsListQueryParser;
use Affilun\Presentation\Admin\Page\LogsPageRenderer;
use Affilun\Presentation\Admin\Page\MigrationPageRenderer;
use Affilun\Presentation\Admin\Page\ProductsListPageRenderer;
use Affilun\Presentation\Admin\Page\ProductsListQueryParser;
use Affilun\Presentation\Admin\Page\SaveSettingsHandler;
use Affilun\Presentation\Admin\Page\SettingsFormParser;
use Affilun\Presentation\Admin\Page\SettingsFormToken;
use Affilun\Presentation\Admin\Page\SettingsPageRenderer;
use Affilun\Presentation\Admin\Page\SyncJobsListQueryParser;
use Affilun\Presentation\Admin\Page\SyncJobsPageRenderer;
use Affilun\Presentation\Rest\Mapper\ProductSummaryMapper;
use Affilun\Presentation\Rest\ProductsServiceProvider;
use Affilun\Presentation\Rest\ProviderServiceProvider;
use Affilun\Domain\Sync\RetryPolicy;
use Affilun\Domain\Sync\SyncPriority;
use DateTimeImmutable;
use DateTimeZone;
use LogicException;
use Throwable;

/**
 * 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_affilun_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 `affilun_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',
		'amazon_resync_queued',
		'amazon_resync_none',
		'amazon_resync_error',
		'external_affiliate_imported',
		'external_affiliate_import_none',
		'external_affiliate_import_error',
		'external_affiliate_notice_hidden',
	);

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

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

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

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

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

	/**
	 * Saved-product CSV export action.
	 */
	private const CSV_EXPORT_ACTION = 'affilun_export_products_csv';

	/**
	 * Saved-product CSV export nonce field.
	 */
	private const CSV_EXPORT_NONCE_FIELD = '_affilun_export_products_csv_nonce';

	/**
	 * Saved-product CSV import action.
	 */
	private const CSV_IMPORT_ACTION = 'affilun_import_products_csv';

	/**
	 * Saved-product CSV import nonce field.
	 */
	private const CSV_IMPORT_NONCE_FIELD = '_affilun_import_products_csv_nonce';

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

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

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

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

	/**
	 * Registry key under which the Phase 4 migration / growth
	 * {@see MigrationPageRenderer} is bound.
	 */
	public const BINDING_MIGRATION_PAGE_RENDERER = 'affilun.admin.migration_page_renderer';

	/**
	 * Registry key under which the Phase 3 click analytics
	 * {@see ClicksDashboardPageRenderer} is bound.
	 */
	public const BINDING_CLICKS_DASHBOARD_RENDERER = 'affilun.admin.clicks_dashboard_renderer';

	/**
	 * Registry key under which the Phase 13 Cloud tools
	 * {@see CloudToolsPageRenderer} is bound.
	 */
	public const BINDING_CLOUD_TOOLS_RENDERER = 'affilun.admin.cloud_tools_renderer';

	/**
	 * Registry key under which the Phase 2 KI-006 follow-up
	 * {@see AuditPageRenderer} is bound.
	 */
	public const BINDING_AUDIT_PAGE_RENDERER = 'affilun.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 = 'affilun.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 4 migration / growth renderer override.
	 */
	private ?MigrationPageRenderer $migrationRendererOverride;

	/**
	 * Phase 3 click analytics renderer override.
	 */
	private ?ClicksDashboardPageRenderer $clicksDashboardRendererOverride;

	/**
	 * 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 settings repository used by admin-post feature gates.
	 */
	private ?OptionStoreSettingsRepository $resolvedSettingsRepository = null;

	/**
	 * Cached option store used by settings-page admin-post handlers.
	 */
	private ?OptionStore $resolvedOptionStore = null;

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

	/**
	 * Cached importer for detected existing affiliate-link data.
	 */
	private ?ExternalAffiliateProductImportUseCase $resolvedExternalAffiliateImporter = 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 MigrationPageRenderer} bound into the registry.
	 */
	private ?MigrationPageRenderer $resolvedMigrationRenderer = null;

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

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

	/**
	 * Cached Cloud AI post scan runner.
	 */
	private ?CloudAiPostScanRunner $resolvedCloudAiPostScanRunner = 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 product repository used by product admin-post handlers.
	 */
	private ?ProductRepository $resolvedProductRepository = null;

	/**
	 * Cached product-meta repository used by product admin-post handlers.
	 */
	private ?ProductMetaRepository $resolvedProductMetaRepository = null;

	/**
	 * Cached sync-job repository used by the Amazon bulk-resync
	 * admin-post handler.
	 */
	private ?SyncJobRepository $resolvedSyncJobRepository = 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.
	 * @param ClicksDashboardPageRenderer|null $clicksDashboardRendererOverride Phase 3 click analytics
	 *                                                                         renderer override.
	 * @param MigrationPageRenderer|null       $migrationRendererOverride        Phase 4 migration 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,
		?ClicksDashboardPageRenderer $clicksDashboardRendererOverride = null,
		?MigrationPageRenderer $migrationRendererOverride = 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;
		$this->clicksDashboardRendererOverride = $clicksDashboardRendererOverride;
		$this->migrationRendererOverride       = $migrationRendererOverride;
	}

	/**
	 * 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 );
		$productMetaRepository = $this->resolveProductMetaRepository( $registry );

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

		$affiliateRoutingRepository = new OptionStoreAffiliateRoutingRepository( $optionStore );
		$externalAffiliateImporter  = new ExternalAffiliateProductImportUseCase(
			$productRepository,
			new SystemClock(),
			self::buildExternalAffiliatePostRowsProvider()
		);
		$renderer                   = $this->rendererOverride ?? new SettingsPageRenderer(
			$repository,
			null,
			$affiliateRoutingRepository,
			$this->resolveOptionalProductCardViewModelFactory( $registry ),
			$this->resolveOptionalProductCardRenderer( $registry ),
			$externalAffiliateImporter,
			$optionStore
		);
		$auditWriter                = $this->resolveOptionalAuditEventWriter( $registry );
		$saveHandler                = $this->saveHandlerOverride ?? new SaveSettingsHandler(
			$repository,
			$useCase,
			$parser,
			null,
			null,
			null,
			null,
			$auditWriter,
			null,
			$affiliateRoutingRepository,
			$this->resolveOptionalRenderCache( $registry )
		);
		$menuRegistrar              = $this->menuRegistrarOverride ?? new AdminMenuRegistrar();

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

		$productTaxonomy         = new ProductManualTaxonomy( $productMetaRepository );
		$bulkProductsUseCase     = new BulkUpdateProductsUseCase( $productRepository, $productTaxonomy );
		$productCsvImportUseCase = new ProductCsvImportUseCase( $productRepository, $productTaxonomy );
		$addNewRenderer          = $this->addNewRendererOverride ?? new AddNewProductPageRenderer();
		$productsListRenderer    = $this->productsListRendererOverride
			?? new ProductsListPageRenderer(
				$listUseCase,
				new ProductSummaryMapper(),
				null,
				$bulkProductsUseCase,
				$productCsvImportUseCase,
				$addNewRenderer,
				$repository,
				$productRepository,
				productMetaRepository: $productMetaRepository,
				amazonDiscountCardRenderer: $this->resolveOptionalAmazonDiscountCardRenderer( $registry )
			);
		$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.
		$providerRegistry   = $this->resolveProviderRegistry( $registry );
		$diagnosticsUseCase = new GetDiagnosticsUseCase(
			$schemaVersionRepository,
			$syncJobRepository,
			$providerRegistry->all(),
			null,
			new SystemClock()
		);

		$diagnosticsRenderer     = $this->diagnosticsRendererOverride
			?? new DiagnosticsPageRenderer( $diagnosticsUseCase );
		$logsRenderer            = $this->logsRendererOverride
			?? new LogsPageRenderer( $listLogsUseCase, $getLogsSummaryUseCase );
		$syncJobsRenderer        = $this->syncJobsRendererOverride
			?? new SyncJobsPageRenderer( $listSyncJobsUseCase );
		$clicksDashboardRenderer = $this->clicksDashboardRendererOverride
			?? new ClicksDashboardPageRenderer(
				$optionStore,
				$this->resolveOptionalClickEventRepository( $registry ),
				$productRepository
			);
		$migrationRenderer       = $this->migrationRendererOverride
			?? new MigrationPageRenderer( $optionStore );
		$topAffiliateCandidates  = $this->resolveOptionalTopAffiliateCandidateRepository( $registry );
		$cloudToolsRenderer      = new CloudToolsPageRenderer(
			$repository,
			productRepository: $productRepository,
			productMetaRepository: $productMetaRepository,
			topAffiliateCandidateRepository: $topAffiliateCandidates,
			optionStore: $optionStore
		);
		$cloudAiPostScanRunner   = new CloudAiPostScanRunner(
			$repository,
			$optionStore,
			$providerRegistry,
			new SaveProductUseCase( $productRepository, new SystemClock() )
		);

		$this->resolvedSettingsRepository        = $repository;
		$this->resolvedOptionStore               = $optionStore;
		$this->resolvedProductRepository         = $productRepository;
		$this->resolvedProductMetaRepository     = $productMetaRepository;
		$this->resolvedExternalAffiliateImporter = $externalAffiliateImporter;
		$this->resolvedSyncJobRepository         = $syncJobRepository;
		$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->resolvedClicksDashboardRenderer   = $clicksDashboardRenderer;
		$this->resolvedMigrationRenderer         = $migrationRenderer;
		$this->resolvedCloudToolsRenderer        = $cloudToolsRenderer;
		$this->resolvedCloudAiPostScanRunner     = $cloudAiPostScanRunner;
		$this->resolvedLogsListQueryParser       = new LogsListQueryParser();
		$this->resolvedSyncJobsListQueryParser   = new SyncJobsListQueryParser();
		$this->resolvedAddNewRenderer            = $addNewRenderer;

		$currentSettings = $repository->load();

		$menuRegistrar->add( $this->buildSettingsPageDefinition() );
		$menuRegistrar->add( $this->buildProductsListPageDefinition() );
		$menuRegistrar->add( $this->buildProductsListSubmenuOverrideDefinition() );
		$menuRegistrar->add( $this->buildAddNewProductPageDefinition( true ) );
		$menuRegistrar->add( $this->buildMigrationPageDefinition( true ) );
		$menuRegistrar->add( $this->buildClicksDashboardPageDefinition() );
		if ( $this->shouldShowCloudContractMenus( $currentSettings ) ) {
			$menuRegistrar->add( $this->buildCloudToolsPageDefinition() );
		}
		$menuRegistrar->add( $this->buildDiagnosticsPageDefinition( true ) );
		$menuRegistrar->add( $this->buildLogsPageDefinition( true ) );
		$menuRegistrar->add( $this->buildSyncJobsPageDefinition( true ) );

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

		$renderer->configureOperationTools(
			$migrationRenderer,
			$diagnosticsRenderer,
			$logsRenderer,
			$syncJobsRenderer,
			$auditRenderer,
			$this->resolvedLogsListQueryParser,
			$this->resolvedSyncJobsListQueryParser,
			$this->resolvedAuditListQueryParser
		);

		$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_CLICKS_DASHBOARD_RENDERER, $clicksDashboardRenderer );
		$registry->bind( self::BINDING_MIGRATION_PAGE_RENDERER, $migrationRenderer );
		$registry->bind( self::BINDING_CLOUD_TOOLS_RENDERER, $cloudToolsRenderer );
		$registry->bind( self::BINDING_ADD_NEW_PAGE_RENDERER, $this->resolvedAddNewRenderer );
		if ( $auditRenderer !== null ) {
			$registry->bind( self::BINDING_AUDIT_PAGE_RENDERER, $auditRenderer );
		}
	}

	/**
	 * Whether paid Cloud-only admin menu entries should be visible.
	 *
	 * @param PluginSettings $settings Current settings snapshot.
	 */
	private function shouldShowCloudContractMenus( PluginSettings $settings ): bool {
		return $settings->cloud()->isConfigured();
	}

	/**
	 * Resolve optional click event repository.
	 *
	 * @param ServiceProviderRegistry $registry Registry.
	 */
	private function resolveOptionalClickEventRepository( ServiceProviderRegistry $registry ): ?ClickEventRepository {
		$key = PersistenceServiceProvider::BINDING_CLICK_EVENT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}
		$repository = $registry->get( $key );
		return $repository instanceof ClickEventRepository ? $repository : null;
	}

	/**
	 * Resolve optional top affiliate candidate repository.
	 *
	 * @param ServiceProviderRegistry $registry Registry.
	 */
	private function resolveOptionalTopAffiliateCandidateRepository( ServiceProviderRegistry $registry ): ?TopAffiliateCandidateRepository {
		$key = PersistenceServiceProvider::BINDING_TOP_AFFILIATE_CANDIDATE_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}
		$repository = $registry->get( $key );
		return $repository instanceof TopAffiliateCandidateRepository ? $repository : null;
	}

	/**
	 * Resolve the optional Amazon discount shortcode configurator.
	 */
	private function resolveOptionalAmazonDiscountCardRenderer(
		ServiceProviderRegistry $registry
	): ?AmazonDiscountShortcodeCardRenderer {
		$key = AmazonDiscountServiceProvider::BINDING_ADMIN_CARD_RENDERER;
		if ( ! $registry->has( $key ) ) {
			return null;
		}
		$renderer = $registry->get( $key );
		return $renderer instanceof AmazonDiscountShortcodeCardRenderer ? $renderer : null;
	}

	/**
	 * 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_affilun_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_affilun_save_settings', array( $this, 'onSaveSettingsPost' ) );
		$hooks->addAction( 'admin_post_affilun_delete_product', array( $this, 'onDeleteProductPost' ) );
		$hooks->addAction( 'admin_post_affilun_bulk_products_action', array( $this, 'onBulkProductsActionPost' ) );
		$hooks->addAction( 'admin_post_affilun_bulk_update_products', array( $this, 'onBulkUpdateProductsPost' ) );
		$hooks->addAction( 'admin_post_affilun_create_article_draft', array( $this, 'onCreateArticleDraftPost' ) );
		$hooks->addAction( 'admin_post_affilun_generate_article_draft', array( $this, 'onGenerateArticleDraftPost' ) );
		$hooks->addAction( 'admin_post_' . CloudToolsPageRenderer::REVENUE_STATUS_ACTION, array( $this, 'onRevenueOpportunityStatusPost' ) );
		$hooks->addAction( 'admin_post_' . CloudToolsPageRenderer::AI_SCAN_ACTION, array( $this, 'onCloudAiPostScanPost' ) );
		$hooks->addAction( CloudAiPostScanRunner::CRON_HOOK, array( $this, 'onCloudAiPostScanCron' ) );
		$hooks->addAction( 'admin_post_' . self::CSV_EXPORT_ACTION, array( $this, 'onExportProductsCsvPost' ) );
		$hooks->addAction( 'admin_post_' . self::CSV_IMPORT_ACTION, array( $this, 'onImportProductsCsvPost' ) );
		$hooks->addAction( 'admin_post_affilun_resync_amazon_products', array( $this, 'onAmazonResyncPost' ) );
		$hooks->addAction( 'admin_post_' . SettingsPageRenderer::EXTERNAL_AFFILIATE_IMPORT_ACTION, array( $this, 'onImportExternalAffiliateDataPost' ) );
		$hooks->addAction( 'admin_post_' . SettingsPageRenderer::EXTERNAL_AFFILIATE_DISMISS_ACTION, array( $this, 'onDismissExternalAffiliateDataPost' ) );
		$hooks->addAction( 'admin_notices', array( $this, 'onAdminNotices' ) );
		$hooks->addAction( 'admin_enqueue_scripts', array( $this, 'onAdminEnqueueScripts' ) );
	}

	/**
	 * Render cross-page affilun admin notices.
	 */
	public function onAdminNotices(): void {
		$this->renderLinkHealthAdminNotice();
	}

	/**
	 * Render latest scheduled/manual link-health warnings on affilun admin pages.
	 */
	private function renderLinkHealthAdminNotice(): void {
		if (
			$this->resolvedSettingsRepository === null
			|| $this->resolvedProductRepository === null
			|| $this->resolvedProductMetaRepository === null
		) {
			return;
		}
		if ( ! self::isAffilunAdminPage() ) {
			return;
		}
		if (
			function_exists( 'current_user_can' )
			&& ! current_user_can( AdminCapability::CAP_VIEW_PRODUCTS )
		) {
			return;
		}
		if ( ! $this->resolvedSettingsRepository->load()->cloud()->isConfigured() ) {
			return;
		}

		try {
			$result = $this->resolvedProductRepository->findPaginated(
				new ProductListQuery(
					page: 1,
					perPage: 50,
					lifecycleState: ProductListQuery::LIFECYCLE_STATE_ACTIVE
				)
			);
		} catch ( Throwable $unused ) {
			unset( $unused );
			return;
		}

		$store   = new ProductLinkHealthStatusStore( $this->resolvedProductMetaRepository );
		$broken  = 0;
		$warning = 0;
		foreach ( $result->items() as $record ) {
			$id = $record->id();
			if ( $id === null ) {
				continue;
			}
			$data = $store->readForProduct( $id );
			if ( $data === null ) {
				continue;
			}
			$status     = is_scalar( $data['status'] ?? null ) ? (string) $data['status'] : '';
			$httpStatus = isset( $data['http_status'] ) && is_numeric( $data['http_status'] ) ? (int) $data['http_status'] : 0;
			$bucket     = LinkHealthStatusClassifier::classify( $status, $httpStatus );
			if ( $bucket === 'broken' ) {
				++$broken;
			} elseif ( $bucket === 'warning' ) {
				++$warning;
			}
		}

		if ( $broken > 0 ) {
			echo '<div class="notice notice-warning is-dismissible"><p>';
			echo esc_html( sprintf( '最新のリンク監視で問題のあるアフィリエイトリンクが %d 件見つかっています。', $broken ) );
			echo ' <a href="' . esc_url( self::linkHealthProblemProductsUrl() ) . '">'
				. esc_html( '商品一覧で確認' ) . '</a>';
			echo '</p></div>';
			return;
		}
		if ( $warning > 0 ) {
			echo '<div class="notice notice-info is-dismissible"><p>';
			echo esc_html( sprintf( '最新のリンク監視で確認が必要なアフィリエイトリンクが %d 件あります。', $warning ) );
			echo ' <a href="' . esc_url( self::linkHealthProblemProductsUrl() ) . '">'
				. esc_html( '商品一覧で確認' ) . '</a>';
			echo '</p></div>';
		}
	}

	/**
	 * Whether the current request is for an affilun admin page.
	 */
	private static function isAffilunAdminPage(): bool {
		return str_starts_with( self::requestKey( self::currentGetData(), 'page' ), 'affilun-' );
	}

	/**
	 * `admin_enqueue_scripts` callback for small admin-only helpers.
	 */
	public function onAdminEnqueueScripts(): void {
		if ( function_exists( 'wp_enqueue_style' ) && defined( 'AFFILUN_PLUGIN_URL' ) ) {
			$pluginDir = defined( 'AFFILUN_PLUGIN_DIR' ) ? (string) constant( 'AFFILUN_PLUGIN_DIR' ) : dirname( __DIR__, 3 ) . '/';

			$cardCssPath = $pluginDir . 'assets/css/affilun-card.css';
			$cardVersion = file_exists( $cardCssPath )
				? (string) filemtime( $cardCssPath )
				: ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' );
			wp_enqueue_style(
				'affilun-card-style',
				AFFILUN_PLUGIN_URL . 'assets/css/affilun-card.css',
				array(),
				$cardVersion
			);

			$adminCssPath = $pluginDir . 'assets/css/affilun-admin.css';
			$adminVersion = file_exists( $adminCssPath )
				? (string) filemtime( $adminCssPath )
				: ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' );
			wp_enqueue_style(
				'affilun-admin-brand',
				AFFILUN_PLUGIN_URL . 'assets/css/affilun-admin.css',
				array( 'affilun-card-style' ),
				$adminVersion
			);
		}

		if ( ! function_exists( 'wp_register_script' ) || ! function_exists( 'wp_enqueue_script' ) ) {
			return;
		}

		$handle = 'affilun-admin-settings';
		wp_register_script( $handle, '', array( 'wp-api-fetch' ), '0.4.0', true );
		wp_enqueue_script( $handle );

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

		$script = <<<'JS'
		(function () {
		  function toArray(nodes) {
		    return Array.prototype.slice.call(nodes || []);
		  }

		  function initSettingsTabs() {
		    var root = document.querySelector('[data-affilun-settings-tabs]');
		    if (!root) {
		      return;
		    }

		    var tabs = toArray(root.querySelectorAll('[data-affilun-tab-target]'));
		    var panels = toArray(root.querySelectorAll('[data-affilun-tab-panel]'));
		    var submitRow = document.querySelector('[data-affilun-settings-submit-row]');
		    if (!tabs.length || !panels.length) {
		      return;
		    }

		    function tabExists(tabKey) {
		      return tabs.some(function (tab) {
		        return tab.getAttribute('data-affilun-tab-target') === tabKey;
		      });
		    }

		    function showTab(tabKey, persist) {
		      if (!tabExists(tabKey)) {
		        tabKey = tabs[0].getAttribute('data-affilun-tab-target');
		      }

		      tabs.forEach(function (tab) {
		        var isActive = tab.getAttribute('data-affilun-tab-target') === tabKey;
		        tab.classList.toggle('nav-tab-active', isActive);
		        tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
		        tab.setAttribute('tabindex', isActive ? '0' : '-1');
		      });

		      panels.forEach(function (panel) {
		        var isActive = panel.getAttribute('data-affilun-tab-panel') === tabKey;
		        panel.classList.toggle('affilun-settings-tab-panel-active', isActive);
		        panel.hidden = !isActive;
		      });

		      if (submitRow) {
		        submitRow.hidden = tabKey === 'cloud' || tabKey === 'operations';
		      }

		      if (persist && window.localStorage) {
		        try {
		          window.localStorage.setItem('affilun-settings-active-tab', tabKey);
		        } catch (ignored) {
		          // localStorage may be unavailable in privacy-restricted contexts.
		        }
		      }
		    }

		    function tabFromHash() {
		      if (!window.location.hash) {
		        return '';
		      }
		      var hash = window.location.hash.replace(/^#/, '');
		      var panel = panels.filter(function (candidate) {
		        return candidate.id === hash;
		      })[0];
		      return panel ? panel.getAttribute('data-affilun-tab-panel') : '';
		    }

		    function storedTab() {
		      if (!window.localStorage) {
		        return '';
		      }
		      try {
		        return window.localStorage.getItem('affilun-settings-active-tab') || '';
		      } catch (ignored) {
		        return '';
		      }
		    }

		    tabs.forEach(function (tab, index) {
		      tab.addEventListener('click', function (event) {
		        event.preventDefault();
		        var tabKey = tab.getAttribute('data-affilun-tab-target');
		        showTab(tabKey, true);
		        if (window.history && window.history.replaceState) {
		          window.history.replaceState(null, '', tab.getAttribute('href'));
		        }
		      });

		      tab.addEventListener('keydown', function (event) {
		        var nextIndex = index;
		        if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
		          nextIndex = (index + 1) % tabs.length;
		        } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
		          nextIndex = (index - 1 + tabs.length) % tabs.length;
		        } else if (event.key === 'Home') {
		          nextIndex = 0;
		        } else if (event.key === 'End') {
		          nextIndex = tabs.length - 1;
		        } else {
		          return;
		        }

		        event.preventDefault();
		        tabs[nextIndex].focus();
		        showTab(tabs[nextIndex].getAttribute('data-affilun-tab-target'), true);
		      });
		    });

		    showTab(tabFromHash() || storedTab() || tabs[0].getAttribute('data-affilun-tab-target'), false);
		  }

		  function initRenderingPreview() {
		    var root = document.querySelector('[data-affilun-rendering-preview]');
		    var card = root ? root.querySelector('[data-affilun-preview-card]') : null;
		    if (!root || !card) {
		      return;
		    }

		    var fieldNames = {
		      template: 'affilun_settings[rendering][default_template]',
		      design: 'affilun_settings[rendering][default_design_preset]',
		      colorScheme: 'affilun_settings[rendering][default_color_scheme]',
		      layout: 'affilun_settings[rendering][default_button_layout]',
		      shape: 'affilun_settings[rendering][default_button_shape]',
		      tone: ''
		    };
		    var templates = {
		      'single-default-card': '標準カード',
		      'single-compact-card': 'コンパクトカード',
		      'single-minimal-cta': 'ミニマルCTA'
		    };
		    var designs = {
		      default: '標準',
		      flat: 'フラット',
		      outlined: 'アウトライン',
		      soft: 'ソフト'
		    };
		    var colorSchemes = {
		      light: 'ライト',
		      dark: 'ダーク',
		      system: 'OS設定に連動'
		    };
		    var layouts = {
		      wrap: '横並び / 折り返し',
		      stacked: '縦積み',
		      full: '全幅'
		    };
		    var shapes = {
		      square: '角丸なし',
		      rounded: '標準角丸',
		      pill: '丸型'
		    };

		    var providers = {
		      amazon: { cssToken: 'amazon', defaultColor: '#f6a306' },
		      rakuten: { cssToken: 'rakuten', defaultColor: '#cf4944' },
		      yahoo_shopping: { cssToken: 'yahoo', defaultColor: '#51a7e8' },
		      mercari: { cssToken: 'mercari', defaultColor: '#f2a1a1' }
		    };

		    function findField(name) {
		      return document.querySelector('[name="' + name + '"]');
		    }

		    function allowedValue(control, allowed, fallback) {
		      var value = control && control.value ? control.value : fallback;
		      return Object.prototype.hasOwnProperty.call(allowed, value) ? value : fallback;
		    }

		    function setText(selector, value) {
		      var target = root.querySelector(selector);
		      if (target) {
		        target.textContent = value;
		      }
		    }

		    function providerField(provider, field) {
		      return findField('affilun_settings[providers][' + provider + '][' + field + ']');
		    }

		    function validHexColor(value) {
		      return /^#[0-9a-fA-F]{6}$/.test(value || '');
		    }

		    function relativeLuminance(color) {
		      var red = parseInt(color.slice(1, 3), 16);
		      var green = parseInt(color.slice(3, 5), 16);
		      var blue = parseInt(color.slice(5, 7), 16);
		      function channel(value) {
		        value = value / 255;
		        return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
		      }
		      return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue);
		    }

		    function softClampLuminance(luminance) {
		      var threshold = 0.022;
		      return luminance >= threshold ? luminance : luminance + Math.pow(threshold - luminance, 1.414);
		    }

		    function apcaContrast(textColor, backgroundColor) {
		      var textY = softClampLuminance(relativeLuminance(textColor));
		      var backgroundY = softClampLuminance(relativeLuminance(backgroundColor));
		      if (Math.abs(backgroundY - textY) < 0.0005) {
		        return 0;
		      }
		      if (backgroundY > textY) {
		        var darkTextContrast = (Math.pow(backgroundY, 0.56) - Math.pow(textY, 0.57)) * 1.14;
		        return darkTextContrast < 0.1 ? 0 : (darkTextContrast - 0.027) * 100;
		      }
		      var lightTextContrast = (Math.pow(backgroundY, 0.65) - Math.pow(textY, 0.62)) * 1.14;
		      return lightTextContrast > -0.1 ? 0 : (lightTextContrast + 0.027) * 100;
		    }

		    function automaticButtonTextColor(color) {
		      if (!validHexColor(color)) {
		        color = '#111827';
		      }
		      return Math.abs(apcaContrast('#ffffff', color)) > Math.abs(apcaContrast('#000000', color)) ? '#ffffff' : '#000000';
		    }

		    function resolveButtonTextColor(color, preference) {
		      if (preference === 'white') {
		        return '#ffffff';
		      }
		      if (preference === 'black') {
		        return '#000000';
		      }
		      return automaticButtonTextColor(color);
		    }



		    var controls = {
		      template: findField(fieldNames.template),
		      design: findField(fieldNames.design),
		      colorScheme: findField(fieldNames.colorScheme),
		      layout: findField(fieldNames.layout),
		      shape: findField(fieldNames.shape),
		      tone: null
		    };

		    function refreshProviders() {
		      var visible = 0;
		      Object.keys(providers).forEach(function (provider) {
		        var enabled = providerField(provider, 'enabled');
		        var color = providerField(provider, 'button_color');
		        var textColor = providerField(provider, 'button_text_color');
		        var button = root.querySelector('[data-affilun-preview-provider="' + provider + '"]');
		        var isEnabled = enabled ? !!enabled.checked : false;
		        var colorValue = color && validHexColor(color.value) ? color.value : providers[provider].defaultColor;
		        var textColorValue = textColor && textColor.value ? textColor.value : 'auto';
		        card.style.setProperty('--affilun-color-' + providers[provider].cssToken, colorValue);
		        card.style.setProperty('--affilun-color-' + providers[provider].cssToken + '-text', resolveButtonTextColor(colorValue, textColorValue));
		        if (button) {
		          button.hidden = !isEnabled;
		        }
		        if (isEnabled) {
		          visible += 1;
		        }
		      });
		      var empty = root.querySelector('[data-affilun-preview-no-providers]');
		      if (empty) {
		        empty.hidden = visible > 0;
		      }
		    }

		    function refreshPreview() {
		      var template = allowedValue(controls.template, templates, 'single-default-card');
		      var design = allowedValue(controls.design, designs, 'default');
		      var colorScheme = allowedValue(controls.colorScheme, colorSchemes, 'light');
		      var layout = allowedValue(controls.layout, layouts, 'wrap');
		      var shape = allowedValue(controls.shape, shapes, 'rounded');
		      var classes = ['affilun-card', 'affilun-card-' + template, 'affilun-provider-amazon'];
		      if (design !== 'default') {
		        classes.push('affilun-design-' + design);
		      }
		      classes.push('affilun-color-scheme-' + colorScheme);
		      if (layout !== 'wrap') {
		        classes.push('affilun-buttons-' + layout);
		      }
		      if (shape !== 'rounded') {
		        classes.push('affilun-button-shape-' + shape);
		      }
		      card.className = classes.join(' ');
		      root.setAttribute('data-affilun-preview-template', template);
		      setText('[data-affilun-preview-template-label]', templates[template] + ' (' + template + ')');
		      setText('[data-affilun-preview-design-label]', designs[design] + ' (' + design + ')');
		      setText('[data-affilun-preview-color-scheme-label]', colorSchemes[colorScheme] + ' (' + colorScheme + ')');
		      setText('[data-affilun-preview-button-label]', layouts[layout] + ' / ' + shapes[shape]);
		      refreshProviders();
		    }

		    toArray([controls.template, controls.design, controls.colorScheme, controls.layout, controls.shape, controls.tone]).forEach(function (control) {
		      if (!control) {
		        return;
		      }
		      control.addEventListener('change', refreshPreview);
		      control.addEventListener('input', refreshPreview);
		    });
		    Object.keys(providers).forEach(function (provider) {
		      var enabled = providerField(provider, 'enabled');
		      var color = providerField(provider, 'button_color');
		      var textColor = providerField(provider, 'button_text_color');
		      toArray([enabled, color, textColor]).forEach(function (control) {
		        if (!control) {
		          return;
		        }
		        control.addEventListener('change', refreshPreview);
		        control.addEventListener('input', refreshPreview);
		      });
		    });
		    refreshPreview();
		  }

		  function initCloudWizard() {
		    var root = document.querySelector('[data-affilun-cloud-onboarding]');
		    var startButton = document.getElementById('affilun-cloud-start-button');
		    var accountConnectButton = document.getElementById('affilun-cloud-account-connect-button');
		    var startRow = document.getElementById('affilun-cloud-start-row');
		    var wizard = document.getElementById('affilun-cloud-wizard');
		    var noticeMessage = document.getElementById('affilun-cloud-onboarding-message');
		    var planSelect = document.getElementById('affilun-cloud-plan-select');
		    var featureSummary = document.getElementById('affilun-cloud-feature-summary');
		    var priceSummary = document.getElementById('affilun-cloud-price-summary');
		    var disclaimer = document.getElementById('affilun-cloud-disclaimer');
		    var freeTrialSummary = document.getElementById('affilun-cloud-free-trial-summary');
		    var purchaseButton = document.getElementById('affilun-cloud-purchase-button');
		    var status = document.getElementById('affilun-cloud-wizard-status');
		    if (!root || !wizard) {
		      return;
		    }
		    if (root.getAttribute('data-affilun-cloud-configured') !== '1' && !startButton) {
		      return;
		    }

		    var freeTrialCopy = 'Free は接続維持用です。Cloud AI、AIスキャン、記事下書きは有料またはトライアル中のプランで利用できます。';
		    var planCatalog = {
		      personal: {
		        features: ['1サイトまで', 'AIキーワード抽出', 'AIスキャン機能', 'AI記事下書き生成', 'キャンペーン提案', 'リンクヘルスチェック', '利用状況の確認'],
		        prices: { monthly: { display: 'Stripe Checkout で確定' }, yearly: { display: 'Stripe Checkout で確定' } },
		        disclaimer: '購入前に金額、契約期間、提供機能、解約条件を確認してください。'
		      },
		      growth: {
		        features: ['5サイトまで', 'Personal の全機能', '分析レコメンド', '分析イベント同期', 'スケジュール型リンクヘルス'],
		        prices: { monthly: { display: 'Stripe Checkout で確定' }, yearly: { display: 'Stripe Checkout で確定' } },
		        disclaimer: '購入前に金額、契約期間、提供機能、解約条件を確認してください。'
		      },
		      agency: {
		        features: ['サイト数制限なし', 'Growth の全機能', 'より高い利用枠', '複数サイト管理'],
		        prices: { monthly: { display: 'Stripe Checkout で確定' }, yearly: { display: 'Stripe Checkout で確定' } },
		        disclaimer: '購入前に金額、契約期間、提供機能、解約条件を確認してください。'
		      }
		    };
		    var plansLoaded = false;

		    function siteLimitFeature(siteLimit) {
		      if (siteLimit === null) {
		        return 'サイト数制限なし';
		      }
		      if (siteLimit === 1) {
		        return '1サイトまで';
		      }
		      if (typeof siteLimit === 'number' && siteLimit > 1) {
		        return siteLimit + 'サイトまで';
		      }
		      return '';
		    }

		    function hideOnboardingEntryPoint() {
		      if (startRow) {
		        startRow.hidden = true;
		      }
		      if (startButton) {
		        startButton.hidden = true;
		        startButton.setAttribute('aria-expanded', 'false');
		      }
		      if (wizard) {
		        wizard.hidden = true;
		      }
		    }

		    function updateCloudHiddenFields(cloud) {
		      var enabled = document.getElementById('affilun-cloud-enabled');
		      var siteId = document.getElementById('affilun-cloud-site-id');
		      var plan = document.getElementById('affilun-cloud-plan');
		      if (enabled) { enabled.value = '1'; }
		      if (siteId) { siteId.value = cloud && cloud.site_id ? cloud.site_id : ''; }
		      if (plan) { plan.value = cloud && cloud.plan ? cloud.plan : ''; }
		    }

		    function markCloudConfigured(cloud) {
		      var planLabel = cloud && cloud.plan ? cloud.plan : '不明';
		      root.setAttribute('data-affilun-cloud-configured', '1');
		      updateCloudHiddenFields(cloud || {});
		      if (noticeMessage) {
		        noticeMessage.textContent = 'affilun Cloud は設定済みです。現在のプラン: ' + planLabel;
		      }
		      if (status) {
		        status.textContent = '';
		      }
		      if (purchaseButton) {
		        purchaseButton.disabled = true;
		      }
		      hideOnboardingEntryPoint();
		    }

		    function markRemoteCloudAccess(data) {
		      var planLabel = data && data.plan ? data.plan : '不明';
		      root.setAttribute('data-affilun-cloud-remote-access', '1');
		      if (noticeMessage) {
		        noticeMessage.textContent = 'affilun Cloud の契約は確認済みです。プラグインから利用するには、メール認証してCloudアカウントに接続してください。現在のプラン: ' + planLabel;
		      }
		      if (purchaseButton) {
		        purchaseButton.textContent = 'メール認証して既存契約に接続';
		      }
		      if (freeTrialSummary) {
		        freeTrialSummary.textContent = 'このサイトは既にCloud側で契約済みです。新規契約ではなく、メール認証後にCloudアカウントへログインして接続を完了してください。';
		      }
		    }

		    function clearCheckoutParams() {
		      if (!window.history || !window.history.replaceState) {
		        return;
		      }
		      var target = new URL(window.location.href);
		      target.searchParams.delete('affilun_cloud_checkout');
		      target.searchParams.delete('session_id');
		      target.searchParams.delete('affilun_cloud_connection_token');
		      target.searchParams.delete('affilun_cloud_connection_state');
		      window.history.replaceState(null, '', target.toString());
		    }

		    function cloudAccountBaseUrl() {
		      var endpoint = document.getElementById('affilun-cloud-endpoint');
		      return endpoint && endpoint.value ? endpoint.value.replace(/\/api\/v1\/?$/, '') : 'https://api.affilun.st';
		    }

		    function accountConnectionRequestData() {
		      var siteUrlInput = document.getElementById('affilun-cloud-site-url');
		      var siteNameInput = document.getElementById('affilun-cloud-site-name');
		      var emailInput = document.getElementById('affilun-cloud-email');
		      var returnUrl = new URL(window.location.href);
		      var state = 'wp_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
		      returnUrl.searchParams.delete('affilun_cloud_connection_token');
		      returnUrl.searchParams.delete('affilun_cloud_connection_state');
		      returnUrl.searchParams.set('affilun_cloud_connection_state', state);
		      window.sessionStorage.setItem('affilun_cloud_connection_state', state);
		      return {
		        site_url: siteUrlInput && siteUrlInput.value ? siteUrlInput.value : window.location.origin,
		        site_name: siteNameInput && siteNameInput.value ? siteNameInput.value : (document.title || window.location.hostname || 'affilun site'),
		        email: emailInput && emailInput.value ? emailInput.value.trim() : '',
		        return_url: returnUrl.toString(),
		        state: state
		      };
		    }

		    function accountConnectUrl(data) {
		      var target = new URL('/account/connect/', cloudAccountBaseUrl());
		      target.searchParams.set('site_url', data.site_url);
		      target.searchParams.set('site_name', data.site_name);
		      if (data.email) {
		        target.searchParams.set('email', data.email);
		      }
		      target.searchParams.set('return_url', data.return_url);
		      target.searchParams.set('state', data.state);
		      return target.toString();
		    }

		    if (root.getAttribute('data-affilun-cloud-configured') === '1') {
		      wizard.hidden = true;
		    }
		    refreshCloudStatusOnLoad();

		    function selectedBillingPeriod() {
		      var checked = document.querySelector('input[name="affilun_cloud_billing_period"]:checked');
		      return checked ? checked.value : 'monthly';
		    }

		    function refreshSummary() {
		      var plan = planSelect ? planSelect.value : 'personal';
		      var periodKey = selectedBillingPeriod();
		      var period = periodKey === 'yearly' ? '年単位' : '月単位';
		      var info = planCatalog[plan] || planCatalog.personal;
		      var price = info.prices && info.prices[periodKey] && info.prices[periodKey].display
		        ? info.prices[periodKey].display
		        : 'Stripe Checkout で確定';
		      if (featureSummary) {
		        featureSummary.textContent = (info.features || []).join('、');
		      }
		      if (priceSummary) {
		        priceSummary.textContent = plan + ' / ' + period + ' — ' + price;
		      }
		      if (disclaimer && info.disclaimer) {
		        disclaimer.textContent = info.disclaimer;
		      }
		      if (freeTrialSummary) {
		        freeTrialSummary.textContent = freeTrialCopy;
		      }
		    }

		    function applyPlansResponse(data) {
		      var plans = data && data.plans && Array.isArray(data.plans) ? data.plans : [];
		      if (!plans.length) {
		        return;
		      }
		      planCatalog = {};
		      if (planSelect) {
		        planSelect.innerHTML = '';
		      }
		      plans.forEach(function (plan) {
		        if (!plan || !plan.code) {
		          return;
		        }
		        var features = Array.isArray(plan.features) ? plan.features.slice() : [];
		        var siteLimit = siteLimitFeature(plan.site_limit);
		        if (siteLimit) {
		          features.unshift(siteLimit);
		        }
		        planCatalog[plan.code] = {
		          features: features,
		          prices: plan.prices || {},
		          disclaimer: plan.disclaimer || data.price_disclaimer || ''
		        };
		        if (planSelect) {
		          var option = document.createElement('option');
		          option.value = plan.code;
		          option.textContent = plan.name || plan.code;
		          planSelect.appendChild(option);
		        }
		      });
		      if (data.free_plan && data.free_plan.message) {
		        freeTrialCopy = data.free_plan.message;
		      }
		      if (data.trial_policy && data.trial_policy.message) {
		        freeTrialCopy += ' ' + data.trial_policy.message;
		      }
		      if (planSelect && data.default_plan && planCatalog[data.default_plan]) {
		        planSelect.value = data.default_plan;
		      }
		    }

		    function loadPlans() {
		      var url = root.getAttribute('data-affilun-cloud-plans-url') || '';
		      var nonce = root.getAttribute('data-affilun-rest-nonce') || '';
		      if (plansLoaded || !url || !window.fetch) {
		        refreshSummary();
		        return Promise.resolve();
		      }
		      plansLoaded = true;
		      if (status) {
		        status.textContent = 'プラン情報を読み込み中…';
		      }
		      return window.fetch(url, {
		        method: 'GET',
		        credentials: 'same-origin',
		        headers: nonce ? { 'X-WP-Nonce': nonce } : {}
		      }).then(function (response) {
		        return response.json().then(function (json) {
		          return { ok: response.ok, json: json };
		        });
		      }).then(function (payload) {
		        if (payload.ok && payload.json && payload.json.data) {
		          applyPlansResponse(payload.json.data);
		          if (status) {
		            status.textContent = '';
		          }
		        } else if (status) {
		          status.textContent = 'プラン情報を取得できませんでした。表示中の内容で続行できます。';
		        }
		        refreshSummary();
		      }).catch(function () {
		        if (status) {
		          status.textContent = 'プラン情報を取得できませんでした。表示中の内容で続行できます。';
		        }
		        refreshSummary();
		      });
		    }

		    function refreshCloudStatusOnLoad() {
		      var url = root.getAttribute('data-affilun-cloud-status-url') || '';
		      var nonce = root.getAttribute('data-affilun-rest-nonce') || '';
		      if (root.getAttribute('data-affilun-cloud-configured') === '1' || !url || !window.fetch) {
		        return;
		      }
		      window.fetch(url, {
		        method: 'POST',
		        credentials: 'same-origin',
		        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
		        body: JSON.stringify({
		          site_url: (document.getElementById('affilun-cloud-site-url') || {}).value || window.location.origin
		        })
		      }).then(function (response) {
		        return response.json().then(function (json) { return { ok: response.ok, json: json }; });
		      }).then(function (payload) {
		        var data = payload.json && payload.json.data ? payload.json.data : null;
		        if (payload.ok && data && data.cloud_access && data.connection_required) {
		          markRemoteCloudAccess(data);
		        }
		      }).catch(function () {});
		    }

		    if (accountConnectButton) {
		      accountConnectButton.addEventListener('click', function () {
		        if (root.getAttribute('data-affilun-cloud-configured') === '1') {
		          if (accountConnectButton.tagName.toLowerCase() === 'button') {
		            window.open(new URL('/account/', cloudAccountBaseUrl()).toString(), '_blank', 'noopener,noreferrer');
		          }
		          return;
		        }
		        window.location.href = accountConnectUrl(accountConnectionRequestData());
		      });
		    }

		    if (startButton) {
		      startButton.addEventListener('click', function () {
		        wizard.hidden = false;
		        startButton.setAttribute('aria-expanded', 'true');
		        loadPlans();
		      });
		    }

		    if (planSelect) {
		      planSelect.addEventListener('change', refreshSummary);
		    }
		    toArray(document.querySelectorAll('input[name="affilun_cloud_billing_period"]')).forEach(function (input) {
		      input.addEventListener('change', refreshSummary);
		    });

		    function withCheckoutParam(url, state) {
		      var target = new URL(url, window.location.href);
		      var sessionPlaceholder = '{CHECKOUT_SESSION_ID}';
		      target.searchParams.set('affilun_cloud_checkout', state);
		      if (state === 'success') {
		        target.searchParams.set('session_id', sessionPlaceholder);
		      }
		      return target.toString().replace(encodeURIComponent(sessionPlaceholder), sessionPlaceholder);
		    }

		    function exchangeConnectionTokenIfPresent() {
		      var params = new URLSearchParams(window.location.search || '');
		      var token = params.get('affilun_cloud_connection_token') || '';
		      var state = params.get('affilun_cloud_connection_state') || '';
		      var expectedState = window.sessionStorage.getItem('affilun_cloud_connection_state') || '';
		      var url = root.getAttribute('data-affilun-cloud-connection-url') || '';
		      var nonce = root.getAttribute('data-affilun-rest-nonce') || '';
		      if (!token || !state || !url || !window.fetch) {
		        return;
		      }
		      if (expectedState && expectedState !== state) {
		        status.textContent = 'Cloud接続の確認に失敗しました。もう一度お試しください。';
		        return;
		      }
		      status.textContent = 'Cloudアカウント接続を確認しています…';
		      window.fetch(url, {
		        method: 'POST',
		        credentials: 'same-origin',
		        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
		        body: JSON.stringify({ connection_token: token, state: state })
		      }).then(function (response) {
		        return response.json().then(function (json) { return { ok: response.ok, json: json }; });
		      }).then(function (payload) {
		        var cloud = payload.json && payload.json.data && payload.json.data.cloud
		          ? payload.json.data.cloud
		          : null;
		        if (payload.ok && cloud && cloud.configured) {
		          window.sessionStorage.removeItem('affilun_cloud_connection_state');
		          markCloudConfigured(cloud);
		          clearCheckoutParams();
		          return;
		        }
		        status.textContent = 'Cloud接続トークンの交換に失敗しました。アカウントページからAPI Keyを再発行してください。';
		      }).catch(function () {
		        status.textContent = 'Cloud接続確認中に通信エラーが発生しました。';
		      });
		    }

		    function provisionAfterCheckout() {
		      var params = new URLSearchParams(window.location.search || '');
		      var state = params.get('affilun_cloud_checkout') || '';
		      var sessionId = params.get('session_id') || '';
		      var url = root.getAttribute('data-affilun-cloud-provision-url') || '';
		      var nonce = root.getAttribute('data-affilun-rest-nonce') || '';
		      if (state !== 'success' || !sessionId || !url || !window.fetch) {
		        return;
		      }
		      wizard.hidden = false;
		      startButton.setAttribute('aria-expanded', 'true');
		      status.textContent = '支払い完了を確認しています…';
		      window.fetch(url, {
		        method: 'POST',
		        credentials: 'same-origin',
		        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
		        body: JSON.stringify({ checkout_session_id: sessionId })
		      }).then(function (response) {
		        return response.json().then(function (json) { return { ok: response.ok, json: json }; });
		      }).then(function (payload) {
		        var cloud = payload.json && payload.json.data && payload.json.data.cloud
		          ? payload.json.data.cloud
		          : null;
		        if (payload.ok && cloud && cloud.configured) {
		          markCloudConfigured(cloud);
		          clearCheckoutParams();
		          return;
		        }
		        status.textContent = '支払いは完了しましたが、API Key の自動設定に失敗しました。しばらく待って再読み込みしてください。';
		      }).catch(function () {
		        status.textContent = '支払い確認中に通信エラーが発生しました。';
		      });
		    }

		    exchangeConnectionTokenIfPresent();
		    provisionAfterCheckout();

		    if (purchaseButton && status) {
		      purchaseButton.addEventListener('click', function () {
		        var connectionEmailUrl = root.getAttribute('data-affilun-cloud-connection-email-url') || '';
		        var nonce = root.getAttribute('data-affilun-rest-nonce') || '';
		        var requestData = accountConnectionRequestData();
		        if (!requestData.email) {
		          status.textContent = 'メールアドレスを入力してください。';
		          return;
		        }
		        status.textContent = '確認メールを送信しています…';
		        window.fetch(connectionEmailUrl, {
		          method: 'POST',
		          credentials: 'same-origin',
		          headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
		          body: JSON.stringify(requestData)
		        }).then(function (response) {
		          return response.json().then(function (json) { return { ok: response.ok, json: json }; });
		        }).then(function (payload) {
		          if (payload.ok) {
		            status.textContent = root.getAttribute('data-affilun-cloud-remote-access') === '1'
		              ? 'メールを送信しました。メールをチェックしてCloudアカウントに接続してください。接続後にこのダッシュボードをリロードしてください。'
		              : 'メールを送信しました。メールをチェックして契約へ進んでください。契約後にこのダッシュボードをリロードしてください。';
		            return;
		          }
		          status.textContent = '確認メールを送信できませんでした。しばらく待ってもう一度お試しください。';
		        }).catch(function () {
		          status.textContent = '確認メール送信中に通信エラーが発生しました。';
		        });
		        return;
		        var url = root.getAttribute('data-affilun-cloud-checkout-url') || '';
		        var nonce = root.getAttribute('data-affilun-rest-nonce') || '';
		        var siteUrlInput = document.getElementById('affilun-cloud-site-url');
		        var emailInput = document.getElementById('affilun-cloud-email');
		        var siteNameInput = document.getElementById('affilun-cloud-site-name');
		        var plan = planSelect ? planSelect.value : 'personal';
		        var period = selectedBillingPeriod();
		        refreshSummary();
		        var email = emailInput && emailInput.value ? emailInput.value.trim() : '';
		        var siteUrl = siteUrlInput && siteUrlInput.value ? siteUrlInput.value.trim() : window.location.origin;
		        var siteName = siteNameInput && siteNameInput.value ? siteNameInput.value.trim() : (document.title || window.location.hostname || 'affilun site');
		        if (siteUrlInput && !siteUrlInput.value) {
		          siteUrlInput.value = siteUrl;
		        }
		        if (siteNameInput && !siteNameInput.value) {
		          siteNameInput.value = siteName;
		        }
		        if (!email) {
		          status.textContent = 'メールアドレスを入力してください。';
		          return;
		        }
		        if (!url || !window.fetch) {
		          status.textContent = 'Checkout API URL が見つかりません。';
		          return;
		        }
		        purchaseButton.disabled = true;
		        status.textContent = 'Checkout セッションを作成中…';
		        window.fetch(url, {
		          method: 'POST',
		          credentials: 'same-origin',
		          headers: {
		            'Content-Type': 'application/json',
		            'X-WP-Nonce': nonce
		          },
		          body: JSON.stringify({
		            email: email,
		            site_url: siteUrl,
		            site_name: siteName,
		            plan: plan,
		            billing_period: period,
		            success_url: withCheckoutParam(window.location.href, 'success'),
		            cancel_url: withCheckoutParam(window.location.href, 'cancel')
		          })
		        }).then(function (response) {
		          return response.json().then(function (json) {
		            return { ok: response.ok, json: json };
		          });
		        }).then(function (payload) {
		          var checkout = payload.json && payload.json.data ? payload.json.data : {};
		          if (payload.ok && checkout.url) {
		            status.textContent = 'Stripe Checkout を開きます…';
		            window.location.href = checkout.url;
		            return;
		          }
		          var message = payload.json && payload.json.error && payload.json.error.message
		            ? payload.json.error.message
		            : 'Checkout セッションを作成できませんでした。入力内容を確認してください。';
		          status.textContent = message;
		        }).catch(function () {
		          status.textContent = 'Checkout セッション作成中に通信エラーが発生しました。';
		        }).then(function () {
		          purchaseButton.disabled = false;
		        });
		      });
		    }
		  }

		  function initProviderColorReset() {
		    toArray(document.querySelectorAll('[data-affilun-reset-color]')).forEach(function (button) {
		      button.addEventListener('click', function () {
		        var inputId = button.getAttribute('data-affilun-reset-color') || '';
		        var input = inputId ? document.getElementById(inputId) : null;
		        if (!input) {
		          return;
		        }
		        var defaultColor = input.getAttribute('data-affilun-default-color') || '';
		        if (defaultColor) {
		          input.value = defaultColor;
		          input.dispatchEvent(new Event('change', { bubbles: true }));
		        }
		      });
		    });
		  }

		  initSettingsTabs();
		  initRenderingPreview();
		  initCloudWizard();
		  initProviderColorReset();
		}());
		JS;

		wp_add_inline_script( $handle, $script );
	}

	/**
	 * `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_affilun_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
	 * `affilun_status` flash literal — so this seam can stay thin.
	 */
	public function onSaveSettingsPost(): void {
		if ( $this->resolvedSaveHandler === null ) {
			return;
		}

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) {
			$this->redirectSettingsPage( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$nonce    = self::postString( $postData, SettingsFormToken::NONCE_FIELD );
		if ( ! wp_verify_nonce( $nonce, SettingsFormToken::NONCE_ACTION ) ) {
			$this->redirectSettingsPage( 'invalid_nonce' );
			return;
		}

		$this->resolvedSaveHandler->handle( $postData );
	}

	/**
	 * `admin_post_affilun_update_revenue_opportunity_status` action callback.
	 */
	public function onRevenueOpportunityStatusPost(): void {
		$postData = self::currentPostData();
		$provider = self::postString( $postData, 'affilun_campaign_provider' );
		$date     = self::postString( $postData, 'affilun_campaign_date' );
		$filter   = self::postString( $postData, 'affilun_revenue_status' );
		$severity = self::postString( $postData, 'affilun_revenue_severity' );
		$type     = self::postString( $postData, 'affilun_revenue_type' );
		$redirect = array(
			'affilun_campaign_provider' => self::cleanRevenueRedirectProvider( $provider ),
			'affilun_campaign_date'     => self::cleanRevenueRedirectDate( $date ),
			'affilun_revenue_status'    => self::cleanRevenueRedirectStatus( $filter ),
			'affilun_revenue_severity'  => self::cleanRevenueRedirectSeverity( $severity ),
			'affilun_revenue_type'      => self::cleanRevenueRedirectType( $type ),
		);

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_CLICKS ) ) {
			$this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) );
			return;
		}
		if ( $this->resolvedSettingsRepository === null ) {
			$this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) );
			return;
		}

		$opportunityId = self::postString( $postData, 'affilun_revenue_opportunity_id' );
		$status        = self::postString( $postData, 'affilun_revenue_opportunity_status' );
		$nonce         = self::postString( $postData, CloudToolsPageRenderer::REVENUE_STATUS_NONCE_FIELD );
		if ( $opportunityId === '' || ! in_array( $status, array( 'new', 'seen', 'applied', 'dismissed' ), true ) ) {
			$this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) );
			return;
		}
		if ( ! wp_verify_nonce( $nonce, CloudToolsPageRenderer::revenueOpportunityStatusNonceAction( $opportunityId, $status ) ) ) {
			$this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) );
			return;
		}

		try {
			$cloud = $this->resolvedSettingsRepository->load()->cloud();
			( new CloudCampaignFeedClient() )->updateRevenueOpportunityStatus(
				$cloud,
				$opportunityId,
				$status,
				time(),
				bin2hex( random_bytes( 8 ) )
			);
			$this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_updated' => '1' ) );
		} catch ( Throwable ) {
			$this->redirectCloudToolsWithArgs( $redirect + array( 'affilun_revenue_status_error' => '1' ) );
		}
	}

	/**
	 * `admin_post_affilun_cloud_ai_post_scan` action callback.
	 */
	public function onCloudAiPostScanPost(): void {
		$postData = self::currentPostData();
		$filter   = self::cleanCloudAiScanFilter( self::postString( $postData, 'affilun_ai_scan_filter' ) );
		$redirect = array( 'affilun_ai_scan_filter' => $filter );

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_CLICKS ) ) {
			$this->redirectCloudToolsWithArgs(
				$redirect + array(
					'affilun_ai_scan_done'   => '1',
					'affilun_ai_scan_failed' => '1',
				)
			);
			return;
		}
		if ( $this->resolvedCloudAiPostScanRunner === null ) {
			$this->redirectCloudToolsWithArgs(
				$redirect + array(
					'affilun_ai_scan_done'   => '1',
					'affilun_ai_scan_failed' => '1',
				)
			);
			return;
		}

		$nonce = self::postString( $postData, CloudToolsPageRenderer::AI_SCAN_NONCE_FIELD );
		if ( ! wp_verify_nonce( $nonce, CloudToolsPageRenderer::AI_SCAN_NONCE_ACTION ) ) {
			$this->redirectCloudToolsWithArgs(
				$redirect + array(
					'affilun_ai_scan_done'   => '1',
					'affilun_ai_scan_failed' => '1',
				)
			);
			return;
		}

		$postIds = self::postIntList( $postData, 'affilun_ai_scan_post_ids' );
		$job     = $this->resolvedCloudAiPostScanRunner->enqueue( $postIds );
		$this->scheduleCloudAiPostScan();
		$this->redirectCloudToolsWithArgs(
			$redirect
			+ array(
				'affilun_ai_scan_queued'   => '1',
				'affilun_ai_scan_job_id'   => is_scalar( $job['id'] ?? null ) ? (string) $job['id'] : '',
				'affilun_ai_scan_selected' => is_numeric( $job['progress_total'] ?? null ) ? (string) max( 0, (int) $job['progress_total'] ) : '0',
			)
		);
	}

	/**
	 * WP-Cron callback for background Cloud AI post scans.
	 */
	public function onCloudAiPostScanCron(): void {
		if ( $this->resolvedCloudAiPostScanRunner === null ) {
			return;
		}

		try {
			$this->resolvedCloudAiPostScanRunner->tick();
			if ( $this->resolvedCloudAiPostScanRunner->hasPendingJobs() ) {
				$this->scheduleCloudAiPostScan();
			}
		} catch ( Throwable $e ) {
			if ( function_exists( 'error_log' ) ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failures need an operator-visible breadcrumb.
				error_log( 'AFFILUN CloudAiPostScanRunner tick failed: ' . $e->getMessage() );
			}
			$this->scheduleCloudAiPostScan( 60 );
		}
	}

	/**
	 * Schedule the next background Cloud AI scan tick.
	 *
	 * @param int $delaySeconds Delay before running the next tick.
	 */
	private function scheduleCloudAiPostScan( int $delaySeconds = 5 ): void {
		if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) {
			return;
		}
		if ( wp_next_scheduled( CloudAiPostScanRunner::CRON_HOOK ) ) {
			return;
		}
		wp_schedule_single_event( time() + max( 1, $delaySeconds ), CloudAiPostScanRunner::CRON_HOOK );
	}

	/**
	 * `admin_post_affilun_delete_product` action callback.
	 *
	 * Marks a product as `deleted_pending` rather than physically
	 * removing the row, so existing embeds can still resolve the saved
	 * snapshot/fallback state and operators can see the product id with
	 * a `(削除済み)` suffix on the products list.
	 */
	public function onDeleteProductPost(): void {
		if ( $this->resolvedProductRepository === null ) {
			return;
		}

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

		$postData  = self::currentPostData();
		$productId = self::postInt( $postData, 'product_id' );
		$nonce     = self::postString( $postData, '_affilun_delete_product_nonce' );

		if ( $productId === null ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}
		if ( ! wp_verify_nonce( $nonce, 'affilun_delete_product_' . $productId ) ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}

		try {
			$this->trashProductById( $productId );
			$this->redirectProductsList( 'deleted' );
		} catch ( Throwable ) {
			$this->redirectProductsList( 'delete_error' );
		}
	}

	/**
	 * Move one product to the logical trash.
	 *
	 * @param int $productId Product id.
	 */
	private function trashProductById( int $productId ): void {
		if ( $this->resolvedProductRepository === null ) {
			return;
		}

		$record = $this->resolvedProductRepository->findById( $productId );
		if ( $record === null || $record->lifecycleState() === ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING ) {
			return;
		}

		$now                    = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
		$row                    = $record->toArray();
		$row['lifecycle_state'] = ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING;
		$row['archived_at']     = $now;
		$row['updated_at']      = $now;
		$this->resolvedProductRepository->save( ProductRecord::fromArray( $row ) );
	}

	/**
	 * Permanently delete one product row.
	 *
	 * @param int $productId Product id.
	 */
	private function deleteProductById( int $productId ): void {
		if ( $this->resolvedProductRepository === null ) {
			return;
		}

		$this->resolvedProductRepository->delete( $productId );
	}

	/**
	 * `admin_post_affilun_bulk_products_action` action callback.
	 */
	public function onBulkProductsActionPost(): void {
		if ( $this->resolvedProductRepository === null ) {
			$this->redirectProductsList( 'bulk_error' );
			return;
		}
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) {
			$this->redirectProductsList( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$nonce    = self::postString( $postData, '_affilun_bulk_products_action_nonce' );
		if ( ! wp_verify_nonce( $nonce, 'affilun_bulk_products_action' ) ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}

		$singleDeleteId = self::postInt( $postData, 'single_delete_id' );
		if ( $singleDeleteId !== null ) {
			try {
				$this->deleteProductById( $singleDeleteId );
			} catch ( Throwable ) {
				$this->redirectProductsList( 'delete_error' );
				return;
			}
			$this->redirectProductsList( 'purged' );
			return;
		}

		$singleTrashId = self::postInt( $postData, 'single_trash_id' );
		if ( $singleTrashId !== null ) {
			try {
				$this->trashProductById( $singleTrashId );
			} catch ( Throwable ) {
				$this->redirectProductsList( 'delete_error' );
				return;
			}
			$this->redirectProductsList( 'deleted' );
			return;
		}

		$bulkAction = self::postString( $postData, 'affilun_bulk_action_select' );
		if ( $bulkAction === '' ) {
			$bulkAction = self::postString( $postData, 'affilun_bulk_action_select2' );
		}
		if ( $bulkAction === 'edit' ) {
			$this->redirectProductsListWithArgs( array( 'affilun_bulk_action' => 'edit' ) );
			return;
		}
		if ( $bulkAction !== 'trash' ) {
			$this->redirectProductsList( 'invalid_bulk' );
			return;
		}

		$productIds = self::postIntList( $postData, 'product_ids' );
		if ( $productIds === array() ) {
			$this->redirectProductsList( 'bulk_trash_none' );
			return;
		}

		try {
			foreach ( $productIds as $productId ) {
				$this->trashProductById( $productId );
			}
		} catch ( Throwable ) {
			$this->redirectProductsList( 'delete_error' );
			return;
		}

		$this->redirectProductsList( 'deleted' );
	}

	/**
	 * `admin_post_affilun_bulk_update_products` action callback.
	 */
	public function onBulkUpdateProductsPost(): void {
		if ( $this->resolvedProductRepository === null || $this->resolvedProductMetaRepository === null ) {
			$this->redirectProductsList( 'bulk_error' );
			return;
		}

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) {
			$this->redirectProductsList( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$nonce    = self::postString( $postData, '_affilun_bulk_update_products_nonce' );
		$filters  = self::bulkFiltersFromPost( $postData );
		$changes  = self::bulkChangesFromPost( $postData );
		if ( ! self::verifyBulkUpdateProductsNonce( $nonce, $filters, $changes ) ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}
		try {
			$result = ( new BulkUpdateProductsUseCase(
				$this->resolvedProductRepository,
				new ProductManualTaxonomy( $this->resolvedProductMetaRepository )
			) )->apply( $filters, $changes );
		} catch ( Throwable ) {
			$this->redirectProductsList( 'bulk_error' );
			return;
		}

		$this->redirectProductsList( $result->updatedCount() > 0 ? 'bulk_updated' : 'bulk_none' );
	}

	/**
	 * Extract bulk filters from POST data.
	 *
	 * @param array<string,mixed> $postData POST data.
	 *
	 * @return array<string,string>
	 */
	private static function bulkFiltersFromPost( array $postData ): array {
		return array(
			'provider'        => self::postString( $postData, 'affilun_bulk_provider' ),
			'lifecycle_state' => self::postString( $postData, 'affilun_bulk_lifecycle_filter' ),
		);
	}

	/**
	 * Extract bulk changes from POST data.
	 *
	 * @param array<string,mixed> $postData POST data.
	 *
	 * @return array<string,mixed>
	 */
	private static function bulkChangesFromPost( array $postData ): array {
		$changes   = array();
		$lifecycle = self::postString( $postData, 'affilun_bulk_lifecycle_change' );
		if ( $lifecycle !== '' ) {
			$changes['lifecycle_state'] = $lifecycle;
		}

		return $changes;
	}

	/**
	 * `admin_post_affilun_create_article_draft` action callback.
	 */
	public function onCreateArticleDraftPost(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) {
			$this->redirectProductsList( 'forbidden' );
			return;
		}
		if ( ! function_exists( 'wp_insert_post' ) ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$postData  = self::currentPostData();
		$productId = self::postInt( $postData, 'product_id' );
		$nonce     = self::postString( $postData, '_affilun_create_article_draft_nonce' );
		if ( $productId === null ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}
		if ( ! wp_verify_nonce( $nonce, ProductsListPageRenderer::createArticleDraftNonceAction( $productId ) ) ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}
		if ( ! $this->isArticleDraftCloudContracted() || ! $this->articleDraftProductExists( $productId ) ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$title   = trim( self::postString( $postData, 'article_title' ) );
		$excerpt = trim( self::postString( $postData, 'article_excerpt' ) );
		$body    = self::postTextareaString( $postData, 'article_body' );
		if ( $title === '' || trim( $body ) === '' ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$postId = self::insertArticleDraftPost( $productId, $title, $excerpt, $body );
		if ( $postId === null ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$this->redirectToPostEditor( $postId );
	}

	/**
	 * `admin_post_affilun_generate_article_draft` action callback.
	 */
	public function onGenerateArticleDraftPost(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) {
			$this->redirectProductsList( 'forbidden' );
			return;
		}
		if ( ! function_exists( 'wp_insert_post' ) ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}
		if ( $this->resolvedProductRepository === null || $this->resolvedSettingsRepository === null ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$postData  = self::currentPostData();
		$productId = self::postInt( $postData, 'product_id' );
		$nonce     = self::postString( $postData, '_affilun_generate_article_draft_nonce' );
		if ( $productId === null ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}
		if ( ! wp_verify_nonce( $nonce, ProductsListPageRenderer::createArticleDraftNonceAction( $productId ) ) ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}

		try {
			$product = $this->resolvedProductRepository->findById( $productId );
			$cloud   = $this->resolvedSettingsRepository->load()->cloud();
		} catch ( Throwable $unused ) {
			unset( $unused );
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		if ( $product === null || ! $cloud->isConfigured() ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		try {
			$options = self::defaultArticleDraftOptions( $product, $cloud );
			$draft   = ( new CloudProductArticleDraftClient() )->generate(
				$cloud,
				self::articleDraftCloudPayload( $product, $options ),
				time(),
				bin2hex( random_bytes( 12 ) )
			);
		} catch ( Throwable $e ) {
			if ( function_exists( 'error_log' ) ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Admin-post failure surface; intentional.
				error_log( 'AFFILUN article draft generation failed: ' . $e->getMessage() );
			}
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$title   = trim( is_scalar( $draft['title'] ?? null ) ? (string) $draft['title'] : '' );
		$excerpt = trim( is_scalar( $draft['excerpt'] ?? null ) ? (string) $draft['excerpt'] : '' );
		$body    = is_scalar( $draft['body_markdown'] ?? null ) ? (string) $draft['body_markdown'] : '';
		if ( $title === '' || trim( $body ) === '' ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$postId = self::insertArticleDraftPost( $productId, $title, $excerpt, $body );
		if ( $postId === null ) {
			$this->redirectProductsList( 'article_draft_error' );
			return;
		}

		$this->redirectToPostEditor( $postId );
	}

	/**
	 * Insert a WordPress draft post with generated article body and product card.
	 *
	 * @param int    $productId Product id.
	 * @param string $title     Post title.
	 * @param string $excerpt   Post excerpt.
	 * @param string $body      Generated Markdown body.
	 */
	private static function insertArticleDraftPost( int $productId, string $title, string $excerpt, string $body ): ?int {
		$content = self::articleMarkdownToPostContent( $body, $productId );
		$postId  = wp_insert_post(
			array(
				'post_title'   => sanitize_text_field( $title ),
				'post_excerpt' => sanitize_text_field( $excerpt ),
				'post_content' => $content,
				'post_status'  => 'draft',
				'post_type'    => 'post',
			),
			true
		);

		if ( function_exists( 'is_wp_error' ) && is_wp_error( $postId ) ) {
			return null;
		}
		if ( ! is_numeric( $postId ) || (int) $postId <= 0 ) {
			return null;
		}

		if ( function_exists( 'update_post_meta' ) ) {
			update_post_meta( (int) $postId, '_affilun_article_draft_product_id', $productId );
			update_post_meta( (int) $postId, '_affilun_article_draft_source', 'affilun_cloud' );
		}

		return (int) $postId;
	}

	/**
	 * Redirect to the post editor when available, otherwise product list.
	 *
	 * @param int $postId Post id.
	 */
	private function redirectToPostEditor( int $postId ): void {
		$url = function_exists( 'get_edit_post_link' ) ? get_edit_post_link( $postId, 'raw' ) : '';
		if ( is_string( $url ) && $url !== '' && function_exists( 'wp_safe_redirect' ) ) {
			wp_safe_redirect( $url );
			exit;
		}

		$this->redirectProductsList( 'article_draft_created' );
	}

	/**
	 * Default Cloud article-draft options from product and settings.
	 *
	 * @param ProductRecord $product Product record.
	 * @param CloudSettings $cloud   Cloud settings.
	 *
	 * @return array<string,mixed>
	 */
	private static function defaultArticleDraftOptions( ProductRecord $product, CloudSettings $cloud ): array {
		$reviewUrls = $cloud->articleDraftReviewSourcePolicy() === 'product_url'
			? array( $product->detailUrl() )
			: array();

		return array(
			'tone'          => $cloud->articleDraftTone(),
			'target_length' => $cloud->articleDraftTargetLength(),
			'review_urls'   => $reviewUrls,
		);
	}

	/**
	 * Cloud article-draft payload.
	 *
	 * @param ProductRecord       $product Product record.
	 * @param array<string,mixed> $options Generation options.
	 *
	 * @return array<string,mixed>
	 */
	private static function articleDraftCloudPayload( ProductRecord $product, array $options ): array {
		return array(
			'provider'         => $product->provider(),
			'provider_item_id' => $product->providerItemId(),
			'title'            => $product->title(),
			'brand'            => $product->brand(),
			'description'      => $product->description(),
			'detail_url'       => $product->detailUrl(),
			'affiliate_url'    => $product->affiliateUrl(),
			'review_urls'      => $options['review_urls'],
			'locale'           => 'ja_JP',
			'tone'             => $options['tone'],
			'target_length'    => $options['target_length'],
		);
	}

	/**
	 * `admin_post_affilun_export_products_csv` action callback.
	 */
	public function onExportProductsCsvPost(): void {
		if ( $this->resolvedProductRepository === null || $this->resolvedProductMetaRepository === null ) {
			$this->redirectProductsList( 'csv_import_error' );
			return;
		}

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) {
			$this->redirectProductsList( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$nonce    = self::postString( $postData, self::CSV_EXPORT_NONCE_FIELD );
		if ( ! wp_verify_nonce( $nonce, self::CSV_EXPORT_ACTION ) ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}

		try {
			$csv = ( new ProductCsvExporter(
				$this->resolvedProductRepository,
				new ProductManualTaxonomy( $this->resolvedProductMetaRepository )
			) )->exportAll();
		} catch ( Throwable ) {
			$this->redirectProductsList( 'csv_import_error' );
			return;
		}

		if ( ! headers_sent() ) {
			header( 'Content-Type: text/csv; charset=UTF-8' );
			header(
				'Content-Disposition: attachment; filename="affilun-products-'
				. gmdate( 'Ymd-His' ) . '.csv"'
			);
			header( 'X-Content-Type-Options: nosniff' );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a generated CSV download to the response body.
		$stream = fopen( 'php://output', 'wb' );
		if ( is_resource( $stream ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite -- CSV cells are escaped by ProductCsvExporter and streamed as the response body.
			fwrite( $stream, "\xEF\xBB\xBF" . $csv );
		}
		exit;
	}

	/**
	 * `admin_post_affilun_import_products_csv` action callback.
	 */
	public function onImportProductsCsvPost(): void {
		if ( $this->resolvedProductRepository === null || $this->resolvedProductMetaRepository === null ) {
			$this->redirectProductsList( 'csv_import_error' );
			return;
		}

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_PRODUCTS_LIST ) ) {
			$this->redirectProductsList( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$payload  = self::postRawString( $postData, 'affilun_product_csv_payload' );
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decodes previewed CSV from a hidden form field, not executable code.
		$csv = base64_decode( $payload, true );
		if ( ! is_string( $csv ) ) {
			$this->redirectProductsList( 'csv_import_error' );
			return;
		}

		$nonce = self::postString( $postData, self::CSV_IMPORT_NONCE_FIELD );
		if ( ! wp_verify_nonce( $nonce, self::csvImportNonceAction( $csv ) ) ) {
			$this->redirectProductsList( 'invalid_nonce' );
			return;
		}

		try {
			$result = ( new ProductCsvImportUseCase(
				$this->resolvedProductRepository,
				new ProductManualTaxonomy( $this->resolvedProductMetaRepository )
			) )->apply( $csv );
		} catch ( Throwable ) {
			$this->redirectProductsList( 'csv_import_error' );
			return;
		}

		$this->redirectProductsList( $result->mutatedCount() > 0 ? 'csv_imported' : 'csv_import_none' );
	}

	/**
	 * Build CSV import nonce action for the exact previewed payload.
	 *
	 * @param string $csv CSV payload.
	 */
	private static function csvImportNonceAction( string $csv ): string {
		return self::CSV_IMPORT_ACTION . '_' . hash( 'sha256', $csv );
	}

	/**
	 * `admin_post_affilun_resync_amazon_products` action callback.
	 *
	 * Enqueues urgent `sync_product` jobs for every active stored
	 * Amazon product so existing link-only snapshots can be refreshed
	 * through the currently configured Amazon provider (Creators API
	 * after Eligibility is granted).
	 */
	public function onAmazonResyncPost(): void {
		if ( $this->resolvedProductRepository === null || $this->resolvedSyncJobRepository === null ) {
			$this->redirectSettingsPage( 'amazon_resync_error' );
			return;
		}

		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) {
			$this->redirectSettingsPage( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$nonce    = self::postString( $postData, SettingsFormToken::AMAZON_RESYNC_NONCE_FIELD );
		if ( ! wp_verify_nonce( $nonce, SettingsFormToken::AMAZON_RESYNC_NONCE_ACTION ) ) {
			$this->redirectSettingsPage( 'invalid_nonce' );
			return;
		}

		try {
			$queued = $this->enqueueAmazonProductSyncJobs();
		} catch ( Throwable ) {
			$this->redirectSettingsPage( 'amazon_resync_error' );
			return;
		}

		$this->redirectSettingsPage( $queued > 0 ? 'amazon_resync_queued' : 'amazon_resync_none' );
	}

	/**
	 * `admin_post_affilun_import_external_affiliate_data` action callback.
	 */
	public function onImportExternalAffiliateDataPost(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) {
			$this->redirectSettingsPage( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$nonce    = self::postString( $postData, SettingsPageRenderer::EXTERNAL_AFFILIATE_IMPORT_NONCE_FIELD );
		if ( ! wp_verify_nonce( $nonce, SettingsPageRenderer::EXTERNAL_AFFILIATE_IMPORT_NONCE_ACTION ) ) {
			$this->redirectSettingsPage( 'invalid_nonce' );
			return;
		}

		if ( $this->resolvedExternalAffiliateImporter === null ) {
			$this->redirectSettingsPage( 'external_affiliate_import_error' );
			return;
		}

		try {
			$result = $this->resolvedExternalAffiliateImporter->import();
			if ( $result->mutatedCount() > 0 && $this->resolvedOptionStore !== null ) {
				$this->resolvedOptionStore->updateNonAutoload(
					SettingsPageRenderer::EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION,
					true
				);
			}
			$this->redirectSettingsPage(
				$result->mutatedCount() > 0 ? 'external_affiliate_imported' : 'external_affiliate_import_none'
			);
		} catch ( Throwable ) {
			$this->redirectSettingsPage( 'external_affiliate_import_error' );
		}
	}

	/**
	 * `admin_post_affilun_dismiss_external_affiliate_data_notice` action callback.
	 */
	public function onDismissExternalAffiliateDataPost(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_SETTINGS ) ) {
			$this->redirectSettingsPage( 'forbidden' );
			return;
		}

		$postData = self::currentPostData();
		$nonce    = self::postString( $postData, SettingsPageRenderer::EXTERNAL_AFFILIATE_DISMISS_NONCE_FIELD );
		if ( ! wp_verify_nonce( $nonce, SettingsPageRenderer::EXTERNAL_AFFILIATE_DISMISS_NONCE_ACTION ) ) {
			$this->redirectSettingsPage( 'invalid_nonce' );
			return;
		}

		if ( $this->resolvedOptionStore === null ) {
			$this->redirectSettingsPage( 'external_affiliate_import_error' );
			return;
		}

		$this->resolvedOptionStore->updateNonAutoload(
			SettingsPageRenderer::EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION,
			true
		);
		$this->redirectSettingsPage( 'external_affiliate_notice_hidden' );
	}

	/**
	 * Enqueue sync jobs for every active Amazon product row.
	 */
	private function enqueueAmazonProductSyncJobs(): int {
		if ( $this->resolvedProductRepository === null || $this->resolvedSyncJobRepository === null ) {
			return 0;
		}

		$dispatcher = new SyncDispatcher(
			$this->resolvedSyncJobRepository,
			new SystemClock(),
			RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT )
		);
		$priority   = SyncPriority::urgent();
		$queued     = 0;
		$page       = 1;

		do {
			$result = $this->resolvedProductRepository->findPaginated(
				new ProductListQuery(
					$page,
					50,
					'amazon',
					null,
					null,
					null,
					ProductListQuery::LIFECYCLE_STATE_ACTIVE,
					ProductListQuery::SORT_KEY_UPDATED_AT,
					ProductListQuery::SORT_ORDER_ASC
				)
			);

			foreach ( $result->items() as $record ) {
				$id = $record->id();
				if ( $id !== null ) {
					$dispatcher->enqueueProductSync( $id, $priority );
					++$queued;
				}
			}

			++$page;
		} while ( $page <= $result->totalPages() );

		return $queued;
	}

	/**
	 * Verify the saved-products bulk update nonce against the preview payload.
	 *
	 * @param string               $nonce   Posted nonce value.
	 * @param array<string,string> $filters Filters.
	 * @param array<string,mixed>  $changes Changes.
	 */
	private static function verifyBulkUpdateProductsNonce( string $nonce, array $filters, array $changes ): bool {
		if ( $nonce === '' || ! function_exists( 'wp_verify_nonce' ) ) {
			return false;
		}

		return (bool) call_user_func(
			'wp_verify_nonce',
			$nonce,
			self::bulkUpdateProductsNonceAction( $filters, $changes )
		);
	}

	/**
	 * Build the nonce action for a previewed bulk payload.
	 *
	 * @param array<string,string> $filters Filters.
	 * @param array<string,mixed>  $changes Changes.
	 */
	private static function bulkUpdateProductsNonceAction( array $filters, array $changes ): string {
		$payload = array(
			'provider'          => $filters['provider'] ?? '',
			'lifecycle_filter'  => $filters['lifecycle_state'] ?? '',
			'category_filter'   => $filters['category'] ?? '',
			'lifecycle_change'  => is_scalar( $changes['lifecycle_state'] ?? null )
				? (string) $changes['lifecycle_state']
				: '',
			'categories'        => is_scalar( $changes['categories'] ?? null )
				? (string) $changes['categories']
				: '',
			'tags'              => is_scalar( $changes['tags'] ?? null )
				? (string) $changes['tags']
				: '',
			'update_categories' => array_key_exists( 'categories', $changes ) ? '1' : '0',
			'update_tags'       => array_key_exists( 'tags', $changes ) ? '1' : '0',
		);

		$encoded = wp_json_encode( $payload );
		return 'affilun_bulk_update_products_' . hash( 'sha256', is_string( $encoded ) ? $encoded : '' );
	}

	/**
	 * Redirect back to the Settings page with a status token.
	 *
	 * @param string $status Status token for the `affilun_status` query parameter.
	 */
	private function redirectSettingsPage( string $status ): void {
		$url = self::resolveAdminPageUrl( 'affilun-settings' );
		if ( function_exists( 'add_query_arg' ) ) {
			$url = (string) add_query_arg( array( 'affilun_status' => $status ), $url );
		} else {
			$url .= ( str_contains( $url, '?' ) ? '&' : '?' ) . 'affilun_status=' . rawurlencode( $status );
		}

		if ( function_exists( 'wp_safe_redirect' ) ) {
			call_user_func( 'wp_safe_redirect', $url );
			exit;
		}
	}

	/**
	 * Build the infrastructure callback that supplies post rows to the import use case.
	 *
	 * @return callable(): list<array{id:int|string, title:string, content:string}>
	 */
	private static function buildExternalAffiliatePostRowsProvider(): callable {
		return static function (): array {
			global $wpdb;
			/** @var \wpdb|null $wpdb */
			if ( ! $wpdb instanceof \wpdb || $wpdb->posts === '' ) {
				return array();
			}

			$likeAsin = '%' . $wpdb->esc_like( '"asin"' ) . '%';
			$likeUrl  = '%' . $wpdb->esc_like( 'amazon_affi_url' ) . '%';
			$sql      = $wpdb->prepare(
				"SELECT ID, post_title, post_content FROM {$wpdb->posts}
				WHERE post_status NOT IN ('trash', 'auto-draft', 'inherit')
				AND (post_content LIKE %s OR post_content LIKE %s)
				ORDER BY ID ASC
				LIMIT 500",
				$likeAsin,
				$likeUrl
			);
			if ( ! is_string( $sql ) || $sql === '' ) {
				return array();
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Read-only bounded detection query prepared above; no reusable cache key because settings page must reflect current post content.
			$rows = $wpdb->get_results( $sql, ARRAY_A );
			if ( ! is_array( $rows ) ) {
				return array();
			}

			$normalized = array();
			foreach ( $rows as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}

				$normalized[] = array(
					'id'      => $row['ID'] ?? '',
					'title'   => is_string( $row['post_title'] ?? null ) ? $row['post_title'] : '',
					'content' => is_string( $row['post_content'] ?? null ) ? $row['post_content'] : '',
				);
			}

			return $normalized;
		};
	}

	/**
	 * Whether affilun Cloud article-draft features are available.
	 */
	private function isArticleDraftCloudContracted(): bool {
		if ( $this->resolvedSettingsRepository === null ) {
			return false;
		}
		try {
			$cloud = $this->resolvedSettingsRepository->load()->cloud();
		} catch ( Throwable ) {
			return false;
		}

		return $cloud->isConfigured() && in_array( strtolower( $cloud->plan() ), array( 'personal', 'growth', 'agency' ), true );
	}

	/**
	 * Whether the product targeted by article-draft creation exists.
	 *
	 * @param int $productId Product id.
	 */
	private function articleDraftProductExists( int $productId ): bool {
		if ( $this->resolvedProductRepository === null ) {
			return false;
		}
		try {
			return $this->resolvedProductRepository->findById( $productId ) !== null;
		} catch ( Throwable ) {
			return false;
		}
	}

	/**
	 * Convert generated Markdown into WordPress block content.
	 *
	 * The Cloud draft endpoint intentionally returns plain Markdown
	 * so the WordPress plugin can make the final block-shape decision
	 * locally.  This converter keeps the supported subset conservative:
	 * headings, paragraphs, ordered/unordered lists, quotes, separators,
	 * and a few safe inline marks are translated to core blocks.  Unknown
	 * Markdown remains escaped text instead of becoming raw HTML.
	 *
	 * @param string $markdown  Generated Markdown.
	 * @param int    $productId Product id.
	 */
	private static function articleMarkdownToPostContent( string $markdown, int $productId ): string {
		$lines  = preg_split( '/\R/u', $markdown );
		$blocks = array();
		if ( ! is_array( $lines ) ) {
			$lines = array( $markdown );
		}
		/** @var list<string> $listItems */
		$listItems = array();
		/** @var 'ordered'|'unordered'|null $listType */
		$listType = null;
		/** @var list<string> $paragraph */
		$paragraph = array();
		/** @var list<string> $quote */
		$quote = array();

		$flushParagraph  = static function () use ( &$blocks, &$paragraph ): void {
			if ( $paragraph === array() ) {
				return;
			}
			$text      = implode( ' ', $paragraph );
			$blocks[]  = '<!-- wp:paragraph --><p>'
				. self::articleInlineMarkdownToHtml( $text )
				. '</p><!-- /wp:paragraph -->';
			$paragraph = array();
		};
		$flushQuote      = static function () use ( &$blocks, &$quote ): void {
			if ( $quote === array() ) {
				return;
			}
			$text     = implode( ' ', $quote );
			$blocks[] = '<!-- wp:quote --><blockquote class="wp-block-quote"><p>'
				. self::articleInlineMarkdownToHtml( $text )
				. '</p></blockquote><!-- /wp:quote -->';
			$quote    = array();
		};
		$flushList       = static function () use ( &$blocks, &$listItems, &$listType ): void {
			if ( $listItems === array() ) {
				return;
			}

			$ordered = $listType === 'ordered';
			$html    = $ordered
				? '<!-- wp:list {"ordered":true} --><ol>'
				: '<!-- wp:list --><ul>';
			foreach ( $listItems as $item ) {
				$html .= '<li>' . self::articleInlineMarkdownToHtml( $item ) . '</li>';
			}
			$html     .= $ordered ? '</ol><!-- /wp:list -->' : '</ul><!-- /wp:list -->';
			$blocks[]  = $html;
			$listItems = array();
			$listType  = null;
		};
		$flushOpenBlocks = static function () use ( $flushParagraph, $flushList, $flushQuote ): void {
			$flushParagraph();
			$flushList();
			$flushQuote();
		};

		foreach ( $lines as $line ) {
			$line = trim( (string) $line );
			if ( $line === '' ) {
				$flushOpenBlocks();
				continue;
			}
			if ( preg_match( '/\A(?:[-*_]\s*){3,}\z/u', $line ) === 1 ) {
				$flushOpenBlocks();
				$blocks[] = '<!-- wp:separator --><hr class="wp-block-separator has-alpha-channel-opacity"/><!-- /wp:separator -->';
				continue;
			}
			if ( preg_match( '/\A(#{1,6})\s+(.+?)(?:\s+#+)?\z/u', $line, $matches ) === 1 ) {
				$flushOpenBlocks();
				$level    = strlen( (string) $matches[1] );
				$level    = min( 6, max( 2, $level ) );
				$heading  = trim( (string) $matches[2] );
				$blocks[] = '<!-- wp:heading {"level":' . (string) $level . '} --><h'
					. (string) $level . '>'
					. self::articleInlineMarkdownToHtml( $heading )
					. '</h' . (string) $level . '><!-- /wp:heading -->';
				continue;
			}
			if ( preg_match( '/\A>\s?(.+)\z/u', $line, $matches ) === 1 ) {
				$flushParagraph();
				$flushList();
				$quote[] = trim( (string) $matches[1] );
				continue;
			}
			if ( preg_match( '/\A([-*+])\s+(.+)\z/u', $line, $matches ) === 1 ) {
				$flushParagraph();
				$flushQuote();
				if ( $listType !== null && $listType !== 'unordered' ) {
					$flushList();
				}
				$listType    = 'unordered';
				$listItems[] = trim( (string) $matches[2] );
				continue;
			}
			if ( preg_match( '/\A\d+[.)]\s+(.+)\z/u', $line, $matches ) === 1 ) {
				$flushParagraph();
				$flushQuote();
				if ( $listType !== null && $listType !== 'ordered' ) {
					$flushList();
				}
				$listType    = 'ordered';
				$listItems[] = trim( (string) $matches[1] );
				continue;
			}

			$flushList();
			$flushQuote();
			$paragraph[] = $line;
		}
		$flushOpenBlocks();

		$blocks[] = '<!-- wp:affilun/product-card {"productId":' . (string) $productId . '} /-->';
		return implode( "\n\n", $blocks );
	}

	/**
	 * Convert a safe inline Markdown subset into HTML.
	 *
	 * @param string $text Inline Markdown text.
	 */
	private static function articleInlineMarkdownToHtml( string $text ): string {
		$tokens = array();

		$withCode = preg_replace_callback(
			'/`([^`\r\n]+)`/u',
			static function ( array $matches ) use ( &$tokens ): string {
				return self::articleMarkdownToken(
					$tokens,
					'<code>' . esc_html( (string) $matches[1] ) . '</code>'
				);
			},
			$text
		);
		if ( is_string( $withCode ) ) {
			$text = $withCode;
		}

		$withLinks = preg_replace_callback(
			'/\[([^\]\r\n]+)\]\((https?:\/\/[^\s)]+)\)/iu',
			static function ( array $matches ) use ( &$tokens ): string {
				$label = trim( (string) $matches[1] );
				$url   = trim( html_entity_decode( (string) $matches[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
				if ( $label === '' || ! self::articleMarkdownIsHttpUrl( $url ) ) {
					return (string) $matches[0];
				}

				$href = self::articleEscUrl( $url );
				if ( $href === '' ) {
					return (string) $matches[0];
				}

				return self::articleMarkdownToken(
					$tokens,
					'<a href="' . $href . '" target="_blank" rel="noopener noreferrer">'
						. esc_html( $label )
						. '</a>'
				);
			},
			$text
		);
		if ( is_string( $withLinks ) ) {
			$text = $withLinks;
		}

		$html = esc_html( $text );

		$withStrong = preg_replace( '/\*\*([^*\r\n]+)\*\*/u', '<strong>$1</strong>', $html );
		if ( is_string( $withStrong ) ) {
			$html = $withStrong;
		}
		$withEmphasis = preg_replace( '/(?<!\*)\*([^*\r\n]+)\*(?!\*)/u', '<em>$1</em>', $html );
		if ( is_string( $withEmphasis ) ) {
			$html = $withEmphasis;
		}

		return strtr( $html, $tokens );
	}

	/**
	 * Register an already-sanitized inline HTML token.
	 *
	 * @param array<string,string> $tokens Token map by reference.
	 * @param string               $html   Sanitized HTML fragment.
	 */
	private static function articleMarkdownToken( array &$tokens, string $html ): string {
		$index = count( $tokens );
		$token = '%%AFLUN_ARTICLE_MD_TOKEN_'
			. (string) $index . '_'
			. substr( hash( 'sha256', $html . ':' . (string) $index ), 0, 12 )
			. '%%';

		$tokens[ $token ] = $html;
		return $token;
	}

	/**
	 * Whether a Markdown link URL is a safe absolute http(s) URL.
	 *
	 * @param string $url Candidate URL.
	 */
	private static function articleMarkdownIsHttpUrl( string $url ): bool {
		if ( preg_match( '/[\x00-\x1F\x7F\s]/u', $url ) === 1 ) {
			return false;
		}

		$parts = function_exists( 'wp_parse_url' )
			? wp_parse_url( $url )
			: call_user_func( 'parse_url', $url );
		if ( ! is_array( $parts ) ) {
			return false;
		}

		$scheme = strtolower( is_string( $parts['scheme'] ?? null ) ? $parts['scheme'] : '' );
		$host   = is_string( $parts['host'] ?? null ) ? trim( $parts['host'] ) : '';

		return $host !== '' && in_array( $scheme, array( 'http', 'https' ), true );
	}

	/**
	 * Escape an inline Markdown URL for href output.
	 *
	 * @param string $url Safe absolute http(s) URL.
	 */
	private static function articleEscUrl( string $url ): string {
		if ( function_exists( 'esc_url' ) ) {
			$escaped = esc_url( $url );
			return is_string( $escaped ) ? $escaped : '';
		}

		return htmlspecialchars( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
	}

	/**
	 * Return sanitized POST data for admin request handlers.
	 *
	 * @return array<string,mixed>
	 */
	private static function currentPostData(): array {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Callers verify the action-specific nonce before mutating state.
		$raw = $_POST;
		return self::sanitizeRequestData( $raw );
	}

	/**
	 * Return sanitized GET data for read-only admin rendering.
	 *
	 * @return array<string,mixed>
	 */
	private static function currentGetData(): array {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin routing/filter input, sanitized before use.
		$raw = $_GET;
		return self::sanitizeRequestData( $raw );
	}

	/**
	 * Sanitize a GET/POST-like array recursively after unslashing.
	 *
	 * @param array<string,mixed> $data Request data.
	 *
	 * @return array<string,mixed>
	 */
	private static function sanitizeRequestData( array $data ): array {
		$unslashed = function_exists( 'wp_unslash' ) ? wp_unslash( $data ) : $data;
		if ( ! is_array( $unslashed ) ) {
			return array();
		}

		$sanitized = array();
		foreach ( $unslashed as $key => $value ) {
			if ( ! is_string( $key ) ) {
				continue;
			}
			$sanitized[ $key ] = self::sanitizeRequestValue( $key, $value );
		}

		return $sanitized;
	}

	/**
	 * Sanitize one request value using the most specific safe context available.
	 *
	 * @param string $key   Request key.
	 * @param mixed  $value Request value.
	 *
	 * @return mixed
	 */
	private static function sanitizeRequestValue( string $key, $value ) {
		if ( is_array( $value ) ) {
			$result = array();
			foreach ( $value as $childKey => $childValue ) {
				if ( is_string( $childKey ) ) {
					$result[ $childKey ] = self::sanitizeRequestValue( $childKey, $childValue );
					continue;
				}
				if ( is_int( $childKey ) ) {
					$result[ $childKey ] = self::sanitizeRequestValue( $key, $childValue );
				}
			}
			return $result;
		}

		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$string = (string) $value;
		if ( str_contains( $key, 'csv' ) || str_contains( $key, 'body' ) || str_contains( $key, 'review_urls' ) ) {
			if ( function_exists( 'sanitize_textarea_field' ) ) {
				return (string) sanitize_textarea_field( $string );
			}
			$string = preg_replace( '/<[^>]*>/', '', $string ) ?? $string;
			return trim( (string) preg_replace( '/[ \t]+/', ' ', $string ) );
		}

		return self::sanitizeTextField( $string );
	}

	/**
	 * Read a sanitize_key()-style token from request data.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 * @param string              $key         Request key.
	 */
	private static function requestKey( array $requestData, string $key ): string {
		if ( ! array_key_exists( $key, $requestData ) || ! is_scalar( $requestData[ $key ] ) ) {
			return '';
		}

		$value = (string) $requestData[ $key ];
		return function_exists( 'sanitize_key' )
			? (string) sanitize_key( $value )
			: (string) preg_replace( '/[^a-z0-9_\-]/i', '', $value );
	}

	/**
	 * Extract a positive integer from POST-like data.
	 *
	 * @param array<string, mixed> $postData POST payload.
	 * @param string               $key      Field key.
	 */
	private static function postInt( array $postData, string $key ): ?int {
		if ( ! array_key_exists( $key, $postData ) || ! is_scalar( $postData[ $key ] ) ) {
			return null;
		}

		$value = trim( (string) $postData[ $key ] );
		if ( $value === '' || ! ctype_digit( $value ) ) {
			return null;
		}

		$id = (int) $value;
		return $id > 0 ? $id : null;
	}

	/**
	 * Extract positive integer list from POST-like data.
	 *
	 * @param array<string, mixed> $postData POST payload.
	 * @param string               $key      Field key.
	 *
	 * @return list<int>
	 */
	private static function postIntList( array $postData, string $key ): array {
		if ( ! array_key_exists( $key, $postData ) || ! is_array( $postData[ $key ] ) ) {
			return array();
		}

		$result = array();
		foreach ( $postData[ $key ] as $value ) {
			if ( ! is_scalar( $value ) ) {
				continue;
			}
			$string = trim( (string) $value );
			if ( $string !== '' && ctype_digit( $string ) && (int) $string > 0 ) {
				$result[] = (int) $string;
			}
		}

		return array_values( array_unique( $result ) );
	}

	/**
	 * Extract a sanitized single-line scalar string from POST-like data.
	 *
	 * @param array<string, mixed> $postData POST payload.
	 * @param string               $key      Field key.
	 */
	private static function postString( array $postData, string $key ): string {
		return self::sanitizeTextField( self::postRawString( $postData, $key ) );
	}

	/**
	 * Extract a sanitized multi-line scalar string from POST-like data.
	 *
	 * @param array<string, mixed> $postData POST payload.
	 * @param string               $key      Field key.
	 */
	private static function postTextareaString( array $postData, string $key ): string {
		$value = self::postRawString( $postData, $key );
		if ( function_exists( 'sanitize_textarea_field' ) ) {
			return (string) call_user_func( 'sanitize_textarea_field', $value );
		}

		$value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value;
		return trim( (string) preg_replace( '/[ \t]+/', ' ', $value ) );
	}

	/**
	 * Extract an unslashed scalar string from POST-like data.
	 *
	 * Use only for fields whose format is validated separately, such as
	 * base64-encoded CSV payloads.
	 *
	 * @param array<string, mixed> $postData POST payload.
	 * @param string               $key      Field key.
	 */
	private static function postRawString( array $postData, string $key ): string {
		if ( ! array_key_exists( $key, $postData ) || ! is_scalar( $postData[ $key ] ) ) {
			return '';
		}

		return (string) $postData[ $key ];
	}

	/**
	 * Sanitize a single-line text field.
	 *
	 * @param string $value Raw value.
	 */
	private static function sanitizeTextField( string $value ): string {
		if ( function_exists( 'sanitize_text_field' ) ) {
			return (string) call_user_func( 'sanitize_text_field', $value );
		}

		$value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value;
		return trim( (string) preg_replace( '/\s+/', ' ', $value ) );
	}

	/**
	 * Clean revenue opportunity redirect provider.
	 *
	 * @param string $provider Raw provider.
	 */
	private static function cleanRevenueRedirectProvider( string $provider ): string {
		$provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( trim( $provider ) ) ) ?? '';
		return $provider !== '' ? $provider : 'amazon';
	}

	/**
	 * Clean revenue opportunity redirect date.
	 *
	 * @param string $date Raw date.
	 */
	private static function cleanRevenueRedirectDate( string $date ): string {
		$date = trim( $date );
		return preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' );
	}

	/**
	 * Clean revenue opportunity redirect status filter.
	 *
	 * @param string $status Raw status.
	 */
	private static function cleanRevenueRedirectStatus( string $status ): string {
		$status = strtolower( trim( $status ) );
		return in_array( $status, array( 'all', 'new', 'seen', 'applied', 'dismissed' ), true ) ? $status : 'new';
	}

	/**
	 * Clean revenue opportunity redirect severity filter.
	 *
	 * @param string $severity Raw severity.
	 */
	private static function cleanRevenueRedirectSeverity( string $severity ): string {
		$severity = strtolower( trim( $severity ) );
		return in_array( $severity, array( 'all', 'high', 'medium', 'low' ), true ) ? $severity : 'all';
	}

	/**
	 * Clean revenue opportunity redirect type filter.
	 *
	 * @param string $type Raw opportunity type.
	 */
	private static function cleanRevenueRedirectType( string $type ): string {
		$type = strtolower( trim( $type ) );
		return in_array(
			$type,
			array(
				'all',
				'broken_link',
				'out_of_stock',
				'sale',
				'provider_event',
				'seasonal',
				'unmonetized_keyword',
				'high_click_stale_copy',
			),
			true
		) ? $type : 'all';
	}

	/**
	 * Clean Cloud AI scan status filter.
	 *
	 * @param string $filter Raw filter.
	 */
	private static function cleanCloudAiScanFilter( string $filter ): string {
		$filter = strtolower( trim( $filter ) );
		return in_array( $filter, array( 'all', 'installed', 'missing', 'no_candidate' ), true ) ? $filter : 'missing';
	}

	/**
	 * Redirect back to the products list with a small status token.
	 *
	 * @param string $status Status token for the `affilun_status` query parameter.
	 */
	private function redirectProductsList( string $status ): void {
		$this->redirectProductsListWithArgs( array( 'affilun_status' => $status ) );
	}

	/**
	 * Redirect back to the products list with query args.
	 *
	 * @param array<string,string> $args Query args.
	 */
	private function redirectProductsListWithArgs( array $args ): void {
		$url = self::resolveAdminPageUrl( 'affilun-products' );
		if ( function_exists( 'add_query_arg' ) ) {
			$url = (string) add_query_arg( $args, $url );
		} else {
			$url .= ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args );
		}

		if ( function_exists( 'wp_safe_redirect' ) ) {
			call_user_func( 'wp_safe_redirect', $url );
			exit;
		}
	}

	/**
	 * Redirect back to the Cloud tools page with query args.
	 *
	 * @param array<string,string> $args Query args.
	 */
	private function redirectCloudToolsWithArgs( array $args ): void {
		$url = self::resolveAdminPageUrl( 'affilun-cloud-tools' );
		if ( function_exists( 'add_query_arg' ) ) {
			$url = (string) add_query_arg( $args, $url );
		} else {
			$url .= ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args );
		}

		if ( function_exists( 'wp_safe_redirect' ) ) {
			call_user_func( 'wp_safe_redirect', $url );
			exit;
		}
	}

	/**
	 * Resolve an admin page URL.
	 *
	 * @param string $pageSlug Admin page slug.
	 */
	private static function resolveAdminPageUrl( string $pageSlug ): string {
		$path = 'admin.php?page=' . rawurlencode( $pageSlug );
		if ( function_exists( 'admin_url' ) ) {
			return (string) admin_url( $path );
		}

		return 'http://example.test/wp-admin/' . $path;
	}

	/**
	 * Resolve the products page focused on saved products with link-health issues.
	 */
	private static function linkHealthProblemProductsUrl(): string {
		$args = array(
			'page'                   => 'affilun-products',
			'affilun_link_health_filter' => 'problem',
		);
		$base = self::resolveAdminPageUrl( 'affilun-products' );
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( array( 'affilun_link_health_filter' => 'problem' ), $base );
		}

		return 'http://example.test/wp-admin/admin.php?' . http_build_query( $args );
	}

	/**
	 * 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 `affilun_status`
	 * query parameter to the cached renderer.
	 */
	private function buildSettingsPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'options-general.php',
			'affilun 設定',
			'affilun',
			AdminCapability::CAP_FALLBACK_MANAGE,
			'affilun-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
	 * "[AFFILUN] under WordPress admin menu — Dashboard / Products /
	 * ..."); a future slice may consolidate Settings under the
	 * same top-level AFFILUN 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(
			'',
			'affilun アフィリエイト管理',
			'affilun',
			AdminCapability::CAP_VIEW_PRODUCTS,
			'affilun-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(
			'affilun-products',
			'affilun アフィリエイト管理',
			'アフィリエイト管理',
			AdminCapability::CAP_VIEW_PRODUCTS,
			'affilun-products',
			// No-op render: the top-level definition above already
			// registers the products-list render closure against the
			// same `toplevel_page_affilun-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 visible submenu under the existing top-level
	 * `affilun-products` AFFILUN menu so WordPress keeps the page routable
	 * from `admin.php?page=affilun-add-new` and the empty-state CTA does
	 * not trip core's "no access" guard. 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`).
	 *
	 * @param bool $hidden Whether to register the page as hidden.
	 */
	private function buildAddNewProductPageDefinition( bool $hidden = false ): AdminPageDefinition {
		return new AdminPageDefinition(
			$hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products',
			'affilun 商品を追加',
			'商品を追加',
			AdminCapability::CAP_VIEW_PRODUCTS,
			'affilun-add-new',
			function (): void {
				$this->renderAddNewProductPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the Phase 4 migration / growth page {@see AdminPageDefinition}.
	 *
	 * @param bool $hidden Whether to register the page as hidden.
	 */
	private function buildMigrationPageDefinition( bool $hidden = false ): AdminPageDefinition {
		return new AdminPageDefinition(
			$hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products',
			'affilun 移行 / スキャン',
			'移行 / スキャン',
			AdminCapability::CAP_VIEW_DIAGNOSTICS,
			'affilun-migration',
			function (): void {
				$this->renderMigrationPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the Phase 3 click analytics page {@see AdminPageDefinition}.
	 */
	private function buildClicksDashboardPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'affilun-products',
			'affilun クリック分析',
			'クリック分析',
			AdminCapability::CAP_VIEW_DIAGNOSTICS,
			'affilun-clicks',
			function (): void {
				$this->renderClicksDashboardPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the Phase 13 Cloud tools page {@see AdminPageDefinition}.
	 */
	private function buildCloudToolsPageDefinition(): AdminPageDefinition {
		return new AdminPageDefinition(
			'affilun-products',
			'affilun Cloudツール',
			'Cloudツール',
			AdminCapability::CAP_VIEW_DIAGNOSTICS,
			'affilun-cloud-tools',
			function (): void {
				$this->renderCloudToolsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §13 Diagnostics admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under the existing top-level
	 * `affilun-products` AFFILUN menu so all Phase-11 admin pages share one
	 * top-level entry. Capability gating uses
	 * {@see AdminCapability::CAP_FALLBACK_MANAGE} (`manage_options`).
	 *
	 * @param bool $hidden Whether to register the page as hidden.
	 */
	private function buildDiagnosticsPageDefinition( bool $hidden = false ): AdminPageDefinition {
		return new AdminPageDefinition(
			$hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products',
			'affilun 診断情報',
			'診断情報',
			AdminCapability::CAP_VIEW_DIAGNOSTICS,
			'affilun-diagnostics',
			function (): void {
				$this->renderDiagnosticsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §6 Logs admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under `affilun-products`. Capability
	 * gating uses {@see AdminCapability::CAP_FALLBACK_MANAGE}
	 * (`manage_options`) since logs may surface admin-tier
	 * diagnostics context.
	 *
	 * @param bool $hidden Whether to register the page as hidden.
	 */
	private function buildLogsPageDefinition( bool $hidden = false ): AdminPageDefinition {
		return new AdminPageDefinition(
			$hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products',
			'affilun ログ',
			'ログ',
			AdminCapability::CAP_VIEW_LOGS,
			'affilun-logs',
			function (): void {
				$this->renderLogsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §6 Sync Jobs admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under `affilun-products`. Capability
	 * gating uses {@see AdminCapability::CAP_FALLBACK_MANAGE}
	 * (`manage_options`).
	 *
	 * @param bool $hidden Whether to register the page as hidden.
	 */
	private function buildSyncJobsPageDefinition( bool $hidden = false ): AdminPageDefinition {
		return new AdminPageDefinition(
			$hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products',
			'affilun 同期ジョブ',
			'同期ジョブ',
			AdminCapability::CAP_RUN_SYNC,
			'affilun-sync-jobs',
			function (): void {
				$this->renderSyncJobsPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Build the §10 Audit Events admin page {@see AdminPageDefinition}.
	 *
	 * Registered as a submenu under `affilun-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}).
	 *
	 * @param bool $hidden Whether to register the page as hidden.
	 */
	private function buildAuditPageDefinition( bool $hidden = false ): AdminPageDefinition {
		return new AdminPageDefinition(
			$hidden ? AdminPageDefinition::HIDDEN_PARENT_SLUG : 'affilun-products',
			'affilun 監査イベント',
			'監査イベント',
			AdminCapability::CAP_VIEW_DIAGNOSTICS,
			'affilun-audit',
			function (): void {
				$this->renderAuditPage();
			},
			'',
			0,
			null
		);
	}

	/**
	 * Page-render dispatch for the Phase 4 migration / growth page.
	 */
	private function renderMigrationPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_MIGRATION ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$unslashedGet  = self::currentGetData();
		$unslashedPost = self::currentPostData();

		$this->resolvedMigrationRenderer->render( array_merge( $unslashedGet, $unslashedPost ) );
	}

	/**
	 * Page-render dispatch for the Phase 3 click analytics page.
	 */
	private function renderClicksDashboardPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_CLICKS ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$this->resolvedClicksDashboardRenderer->render( self::currentGetData() );
	}

	/**
	 * Page-render dispatch for the Phase 13 Cloud tools page.
	 */
	private function renderCloudToolsPage(): void {
		if ( ! $this->isAuthorizedForPage( AdminCapability::PAGE_TOKEN_DIAGNOSTICS ) ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					'Insufficient capabilities.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$this->resolvedCloudToolsRenderer->render( self::currentGetData() );
	}

	/**
	 * 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.',
					'affilun',
					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.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$unslashed = self::currentGetData();
		$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.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$unslashed = self::currentGetData();
		$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.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$unslashed = self::currentGetData();
		$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.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$unslashedGet  = self::currentGetData();
		$unslashedPost = self::currentPostData();
		$requestData   = array_merge( $unslashedGet, $unslashedPost );

		$query = $this->resolvedProductsListQueryParser->parse( $unslashedGet );
		$this->resolvedProductsListRenderer->render( $query, $requestData );
	}

	/**
	 * 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 `affilun_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.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$getData   = self::currentGetData();
		$rawStatus = self::requestKey( $getData, 'affilun_status' );

		$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 `affilun_status` query parameter to the cached
	 * renderer. Unknown status values are dropped silently
	 * rather than echoed back into the page so a malicious
	 * `?affilun_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.',
					'affilun',
					array( 'response' => 403 )
				);
			}
			return;
		}

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

		$getData   = self::currentGetData();
		$rawStatus = self::requestKey( $getData, 'affilun_status' );

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

		$this->resolvedRenderer->render( $flash, self::currentRequestParameters() );
	}

	/**
	 * Return request parameters for read-only operation tab rendering.
	 *
	 * @return array<string, mixed>
	 */
	private static function currentRequestParameters(): array {
		return array_merge( self::currentGetData(), self::currentPostData() );
	}

	/**
	 * 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 {@see ProductMetaRepository} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or invalid.
	 */
	private function resolveProductMetaRepository( ServiceProviderRegistry $registry ): ProductMetaRepository {
		$key = PersistenceServiceProvider::BINDING_PRODUCT_META_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'AdminServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing ProductMetaRepository 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 ProductMetaRepository ) {
			$message = sprintf(
				'AdminServiceProvider expected ProductMetaRepository 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 `affilun_*` 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 render cache so settings saves can invalidate
	 * cached card HTML.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalRenderCache( ServiceProviderRegistry $registry ): ?RenderCache {
		$key = CacheServiceProvider::BINDING_RENDER_CACHE;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

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

	/**
	 * Soft-resolve the canonical ProductCardViewModelFactory for the
	 * Settings rendering preview.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalProductCardViewModelFactory(
		ServiceProviderRegistry $registry
	): ?ProductCardViewModelFactory {
		$key = RenderServiceProvider::BINDING_PRODUCT_CARD_VIEW_MODEL_FACTORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

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

	/**
	 * Soft-resolve the canonical ProductCardRenderer for the Settings
	 * rendering preview.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalProductCardRenderer(
		ServiceProviderRegistry $registry
	): ?ProductCardRenderer {
		$key = RenderServiceProvider::BINDING_PRODUCT_CARD_RENDERER;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

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

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