<?php
/**
 * Settings page renderer.
 *
 * Phase-8 slice 8c-2 server-side renderer for the §13 Settings
 * admin page documented in `specifications/admin-ui-spec.md`.
 * The renderer echoes the tabbed settings form into the admin
 * output stream and is invoked from the page-render closure
 * wired by
 * {@see \Affilun\Presentation\Admin\AdminServiceProvider}.
 *
 * Capability gating is NOT performed by this renderer — the
 * page-render closure in the service provider re-checks
 * `manage_options` via {@see AdminCapability::CAP_FALLBACK_MANAGE}
 * and `wp_die`'s on denial. The renderer just emits HTML.
 *
 * Repository access:
 *
 *   The renderer takes a {@see SettingsRepository} directly
 *   rather than the §13.1
 *   {@see \Affilun\Application\Settings\UseCase\GetPublicSettingsUseCase}
 *   because the admin page MUST surface admin-only fields
 *   (sync TTLs, cache TTLs, debug-mode flag) plus the masked
 *   provider credentials. The public-settings use case strips
 *   all of those, so a separate read seam is required. Per §3
 *   the renderer is admin-only (`manage_options`-gated at the
 *   page-callback level) so it has access to the full state
 *   including the §6.3-masked credential representation.
 *
 * Provider-credential rotation flow (§6.3):
 *
 *   - The current value is rendered next to the label as a
 *     **plaintext** `<span>` so the operator can confirm the
 *     persisted value against the upstream Associates / RMS /
 *     DG console. The Settings page is `manage_options`-gated
 *     at the page-callback level (§3), so only admin-tier
 *     users reach this surface; the operator UX preference
 *     here is to expose the saved value so it can be audited
 *     in place. Logs / diagnostics surfaces continue to mask
 *     via {@see \Affilun\Application\Settings\SettingsCredentialMask}
 *     so secrets never leak into operational telemetry.
 *   - The associated `<input type="text">` carries the
 *     placeholder `(leave blank to keep current)` so the user
 *     understands the rotating-input contract.
 *   - Empty submission preserves the current value; a
 *     non-empty submission rotates. The actual rotation logic
 *     lives in
 *     {@see \Affilun\Application\Settings\UseCase\SaveSettingsUseCase}.
 *
 * Status-flash protocol:
 *
 *   - The optional `$statusFlash` parameter accepts the literal
 *     `'saved'` / `'error'` / `'forbidden'` / `'invalid_nonce'`
 *     and renders the corresponding `notice notice-success` or
 *     `notice notice-error` banner. Other values (and `null`)
 *     suppress the banner.
 *
 * Escaping invariants:
 *
 *   - Every visible text emission flows through
 *     {@see esc_html()}.
 *   - Every attribute value flows through {@see esc_attr()}.
 *   - The `<form action="...">` URL flows through
 *     {@see esc_url()}.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Application\Render\EmbedConfigInput;
use Affilun\Application\Render\ProductCardRenderer;
use Affilun\Application\Render\ViewModel\Parts\ButtonView;
use Affilun\Application\Render\ViewModel\Parts\MetaFlags;
use Affilun\Application\Render\ViewModel\ProductCardViewModel;
use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory;
use Affilun\Application\Cloud\CloudHttpOptions;
use Affilun\Application\Migration\ExternalAffiliateProductImportUseCase;
use Affilun\Application\Settings\CacheSettings;
use Affilun\Application\Settings\CloudSettings;
use Affilun\Application\Settings\LinkDefaults;
use Affilun\Application\Settings\PluginSettings;
use Affilun\Application\Settings\ProviderCredentials;
use Affilun\Application\Settings\RenderingDefaults;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Application\Settings\SyncSettings;
use Affilun\Application\Affiliate\OptionStoreAffiliateRoutingRepository;
use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\ProviderProductId;
use Affilun\Domain\Render\RenderContext;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Presentation\Card\ProductCardVisualSettings;
use DateTimeImmutable;
use DateTimeZone;
use Throwable;

/**
 * Renders the §13 Settings admin page form to stdout.
 *
 * See the file-level docblock for the repository-access
 * rationale, the §6.3 rotation flow for provider credentials,
 * and the status-flash protocol the Save handler drives.
 */
final class SettingsPageRenderer {

	/**
	 * Per-provider field schema used to render the §13.4
	 * Providers section.
	 *
	 * Keyed by provider key; each entry carries the
	 * human-readable provider label and an ordered list of
	 * `[secret_key => human_label]` pairs.
	 *
	 * @var array<string, array{label: string, secrets: array<string, string>}>
	 */
	private const PROVIDER_SCHEMA = array(
		'amazon'         => array(
			'label'   => 'Amazon (Creators API)',
			'secrets' => array(
				'credential_id'     => '認証情報ID',
				'credential_secret' => 'シークレット',
				'version'           => 'バージョン',
				'partner_tag'       => 'StoreID',
			),
		),
		'rakuten'        => array(
			'label'   => '楽天市場',
			'secrets' => array(
				'application_id' => 'Application ID (アプリ ID)',
				'access_key'     => 'Access Key (アクセスキー)',
				'affiliate_id'   => 'Affiliate ID (アフィリエイト ID)',
			),
		),
		'yahoo_shopping' => array(
			'label'   => 'Yahoo!ショッピング',
			'secrets' => array(
				'app_id'        => 'Client ID',
				'affiliate_sid' => 'ValueCommerce sid',
				'affiliate_pid' => 'ValueCommerce pid',
			),
		),
		'mercari'        => array(
			'label'   => 'メルカリ (メルカリアンバサダー)',
			'secrets' => array(
				'afid' => 'メルカリアンバサダー ID (afid)',
			),
		),
	);

	/**
	 * HTML id of the settings-save form. Individual controls are
	 * associated with this form via the HTML5 `form` attribute so
	 * the Advanced tab can contain the standalone Amazon re-sync
	 * form without invalid nested forms.
	 */
	private const SETTINGS_FORM_ID = 'affilun-settings-form';

	/**
	 * Public setup guides shown immediately before each affiliate
	 * enablement checkbox.
	 *
	 * @var array<string, array{label: string, url: string}>
	 */
	private const SETUP_GUIDE_LINKS = array(
		'amazon'         => array(
			'label' => 'Amazonの有効化方法を見る',
			'url'   => 'https://affilun.st/amazon',
		),
		'rakuten'        => array(
			'label' => '楽天市場の有効化方法を見る',
			'url'   => 'https://affilun.st/rakuten',
		),
		'yahoo_shopping' => array(
			'label' => 'Yahoo!ショッピングの有効化方法を見る',
			'url'   => 'https://affilun.st/yahoo',
		),
		'mercari'        => array(
			'label' => 'メルカリの有効化方法を見る',
			'url'   => 'https://affilun.st/mercari',
		),
	);

	/**
	 * Tab definitions for the settings page.
	 *
	 * @var array<string, array{label: string, panel_id: string}>
	 */
	private const TAB_DEFINITIONS = array(
		'basic'          => array(
			'label'    => '基本設定',
			'panel_id' => 'affilun-settings-tab-basic',
		),
		'amazon'         => array(
			'label'    => 'Amazon',
			'panel_id' => 'affilun-settings-tab-amazon',
		),
		'rakuten'        => array(
			'label'    => '楽天市場',
			'panel_id' => 'affilun-settings-tab-rakuten',
		),
		'yahoo_shopping' => array(
			'label'    => 'Yahoo!ショッピング',
			'panel_id' => 'affilun-settings-tab-yahoo-shopping',
		),
		'mercari'        => array(
			'label'    => 'メルカリ',
			'panel_id' => 'affilun-settings-tab-mercari',
		),
		'asp'            => array(
			'label'    => 'ASPルーティング（上級者向け）',
			'panel_id' => 'affilun-settings-tab-asp',
		),
		'cloud'          => array(
			'label'    => 'affilun Cloud',
			'panel_id' => 'affilun-settings-tab-cloud',
		),
		'operations'     => array(
			'label'    => '運用ツール',
			'panel_id' => 'affilun-settings-tab-operations',
		),
		'advanced'       => array(
			'label'    => '高度な設定',
			'panel_id' => 'affilun-settings-tab-advanced',
		),
	);

	/**
	 * Closed set of allowed `placeholder_image_policy` values
	 * mirrored from {@see RenderingDefaults}.
	 *
	 * @var list<string>
	 */
	private const PLACEHOLDER_POLICIES = array( 'none', 'transparent', 'low-res' );

	/**
	 * Closed set of default template choices.
	 *
	 * @var list<string>
	 */
	private const DEFAULT_TEMPLATE_OPTIONS = array( 'single-default-card', 'single-compact-card', 'single-minimal-cta' );

	/**
	 * Closed set of default card design presets.
	 *
	 * @var list<string>
	 */
	private const DESIGN_PRESET_OPTIONS = array( 'default', 'flat', 'outlined', 'soft' );

	/**
	 * Closed set of default card color schemes.
	 *
	 * @var list<string>
	 */
	private const COLOR_SCHEME_OPTIONS = array( 'light', 'dark', 'system' );

	/**
	 * Closed set of default button layouts.
	 *
	 * @var list<string>
	 */
	private const BUTTON_LAYOUT_OPTIONS = array( 'wrap', 'stacked', 'full' );

	/**
	 * Closed set of default button shapes.
	 *
	 * @var list<string>
	 */
	private const BUTTON_SHAPE_OPTIONS = array( 'square', 'rounded', 'pill' );

	/**
	 * Closed set of allowed `retry_policy_preset` values
	 * mirrored from {@see SyncSettings}.
	 *
	 * @var list<string>
	 */
	private const RETRY_POLICY_PRESETS = array( 'none', 'fast', 'balanced', 'patient' );

	/**
	 * Settings repository the renderer reads from.
	 */
	private SettingsRepository $repository;

	/**
	 * Optional ASP affiliate-routing repository.
	 */
	private ?OptionStoreAffiliateRoutingRepository $affiliateRoutingRepository;

	/**
	 * Optional canonical card ViewModel factory for the Settings preview.
	 */
	private ?ProductCardViewModelFactory $previewViewModelFactory;

	/**
	 * Optional canonical product-card renderer for the Settings preview.
	 */
	private ?ProductCardRenderer $previewRenderer;

	/**
	 * Optional embedded migration renderer for the Operations tab.
	 */
	private ?MigrationPageRenderer $migrationRenderer = null;

	/**
	 * Optional embedded diagnostics renderer for the Operations tab.
	 */
	private ?DiagnosticsPageRenderer $diagnosticsRenderer = null;

	/**
	 * Optional embedded logs renderer for the Operations tab.
	 */
	private ?LogsPageRenderer $logsRenderer = null;

	/**
	 * Optional embedded sync-jobs renderer for the Operations tab.
	 */
	private ?SyncJobsPageRenderer $syncJobsRenderer = null;

	/**
	 * Optional embedded audit renderer for the Operations tab.
	 */
	private ?AuditPageRenderer $auditRenderer = null;

	/**
	 * Optional logs query parser for the embedded Operations tab.
	 */
	private ?LogsListQueryParser $logsListQueryParser = null;

	/**
	 * Optional sync-jobs query parser for the embedded Operations tab.
	 */
	private ?SyncJobsListQueryParser $syncJobsListQueryParser = null;

	/**
	 * Optional audit query parser for the embedded Operations tab.
	 */
	private ?AuditListQueryParser $auditListQueryParser = null;

	/**
	 * Current request query forwarded by the admin page dispatcher.
	 *
	 * @var array<string, mixed>
	 */
	private array $operationRequest = array();

	/**
	 * Nonce-field emitter callable.
	 *
	 * Signature: `function (string $action, string $name): void`.
	 * Default forwards to `wp_nonce_field()` when available;
	 * tests inject recording closures.
	 *
	 * @var callable
	 */
	private $nonceFieldEmitter;

	/**
	 * Option key storing the explicit "hide this message" preference.
	 */
	public const EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION = 'affilun_external_affiliate_import_notice_hidden';

	public const EXTERNAL_AFFILIATE_IMPORT_ACTION        = 'affilun_import_external_affiliate_data';
	public const EXTERNAL_AFFILIATE_IMPORT_NONCE_ACTION  = 'affilun_import_external_affiliate_data';
	public const EXTERNAL_AFFILIATE_IMPORT_NONCE_FIELD   = '_affilun_import_external_affiliate_data_nonce';
	public const EXTERNAL_AFFILIATE_DISMISS_ACTION       = 'affilun_dismiss_external_affiliate_data_notice';
	public const EXTERNAL_AFFILIATE_DISMISS_NONCE_ACTION = 'affilun_dismiss_external_affiliate_data_notice';
	public const EXTERNAL_AFFILIATE_DISMISS_NONCE_FIELD  = '_affilun_dismiss_external_affiliate_data_notice_nonce';

	/**
	 * Existing affiliate-link data importer.
	 */
	private ?ExternalAffiliateProductImportUseCase $externalAffiliateImporter;

	/**
	 * Option store for notice preferences.
	 */
	private ?OptionStore $optionStore;

	/**
	 * Construct the renderer.
	 *
	 * @param SettingsRepository                         $repository          Settings repository.
	 * @param callable|null                              $nonceFieldEmitter   Nonce emitter.
	 * @param OptionStoreAffiliateRoutingRepository|null $routingRepository   ASP repo.
	 * @param ProductCardViewModelFactory|null           $previewFactory      Canonical preview ViewModel factory.
	 * @param ProductCardRenderer|null                   $previewRenderer     Canonical preview renderer.
	 * @param ExternalAffiliateProductImportUseCase|null $externalImporter    Existing data importer.
	 * @param OptionStore|null                           $optionStore         Option store for UI preferences.
	 *
	 * @phpstan-param (callable(string,string):void)|null $nonceFieldEmitter
	 */
	public function __construct(
		SettingsRepository $repository,
		?callable $nonceFieldEmitter = null,
		?OptionStoreAffiliateRoutingRepository $routingRepository = null,
		?ProductCardViewModelFactory $previewFactory = null,
		?ProductCardRenderer $previewRenderer = null,
		?ExternalAffiliateProductImportUseCase $externalImporter = null,
		?OptionStore $optionStore = null
	) {
		$this->repository                 = $repository;
		$this->nonceFieldEmitter          = $nonceFieldEmitter ?? self::defaultNonceFieldEmitter();
		$this->affiliateRoutingRepository = $routingRepository;
		$this->previewViewModelFactory    = $previewFactory;
		$this->previewRenderer            = $previewRenderer;
		$this->externalAffiliateImporter  = $externalImporter;
		$this->optionStore                = $optionStore;
	}

	/**
	 * Echo the full Settings page HTML to stdout.
	 *
	 * @param string|null         $statusFlash Optional status flash literal: `'saved'` /
	 *                                         `'error'` / `'forbidden'` / `'invalid_nonce'`.
	 * @param array<string,mixed> $request     Current request parameters for embedded operations.
	 */
	public function render( ?string $statusFlash = null, array $request = array() ): void {
		$settings               = $this->repository->load();
		$formAction             = self::resolveAdminPostUrl();
		$this->operationRequest = $request;

		echo '<div class="wrap affilun-admin-page affilun-settings-page">';
		$this->renderBrandHero();

		$this->renderStatusFlash( $statusFlash );

		echo '<form method="post" action="' . esc_url( $formAction ) . '" id="'
			. esc_attr( self::SETTINGS_FORM_ID ) . '">';
		echo '<input type="hidden" name="action" value="' . esc_attr( SettingsFormToken::FORM_ACTION ) . '">';

		( $this->nonceFieldEmitter )( SettingsFormToken::NONCE_ACTION, SettingsFormToken::NONCE_FIELD );

		echo '</form>';

		echo '<div class="affilun-settings-tabs" data-affilun-settings-tabs>';
		$this->renderTabNavigation();

		$this->renderTabPanelOpen( 'basic' );
		$this->renderExternalAffiliateImportNotice( $formAction );
		$this->renderRenderingSection( $settings );
		echo '<hr>';
		$this->renderLinksSection( $settings->links() );
		$this->renderTabPanelClose();

		$this->renderProviderTabPanel( 'amazon', $settings );
		$this->renderProviderTabPanel( 'rakuten', $settings );
		$this->renderProviderTabPanel( 'yahoo_shopping', $settings );
		$this->renderProviderTabPanel( 'mercari', $settings );

		$this->renderTabPanelOpen( 'asp' );
		$this->renderAffiliateRoutingSection();
		$this->renderTabPanelClose();

		$this->renderTabPanelOpen( 'cloud' );
		$this->renderCloudSection( $settings->cloud() );
		$this->renderTabPanelClose();

		$this->renderTabPanelOpen( 'operations' );
		$this->renderOperationsSection();
		$this->renderTabPanelClose();

		$this->renderTabPanelOpen( 'advanced' );
		$this->renderSyncSection( $settings->sync() );
		echo '<hr>';
		$this->renderCacheSection( $settings->cache() );
		$this->renderAmazonResyncForm( $formAction );
		echo '<hr>';
		$this->renderAdvancedSection( $settings );
		$this->renderTabPanelClose();

		echo '</div>';

		echo '<p class="submit" id="affilun-settings-submit-row" data-affilun-settings-submit-row>';
		echo '<button type="submit" form="' . esc_attr( self::SETTINGS_FORM_ID ) . '" class="button button-primary">';
		echo esc_html( '設定を保存' );
		echo '</button>';
		echo '</p>';

		echo '</div>';
	}

	/**
	 * Configure embedded operation-tool renderers for the Operations tab.
	 *
	 * @param MigrationPageRenderer|null   $migrationRenderer       Migration renderer.
	 * @param DiagnosticsPageRenderer|null $diagnosticsRenderer     Diagnostics renderer.
	 * @param LogsPageRenderer|null        $logsRenderer            Logs renderer.
	 * @param SyncJobsPageRenderer|null    $syncJobsRenderer        Sync jobs renderer.
	 * @param AuditPageRenderer|null       $auditRenderer           Audit renderer.
	 * @param LogsListQueryParser|null     $logsListQueryParser     Logs query parser.
	 * @param SyncJobsListQueryParser|null $syncJobsListQueryParser Sync jobs query parser.
	 * @param AuditListQueryParser|null    $auditListQueryParser    Audit query parser.
	 */
	public function configureOperationTools(
		?MigrationPageRenderer $migrationRenderer,
		?DiagnosticsPageRenderer $diagnosticsRenderer,
		?LogsPageRenderer $logsRenderer,
		?SyncJobsPageRenderer $syncJobsRenderer,
		?AuditPageRenderer $auditRenderer,
		?LogsListQueryParser $logsListQueryParser,
		?SyncJobsListQueryParser $syncJobsListQueryParser,
		?AuditListQueryParser $auditListQueryParser
	): void {
		$this->migrationRenderer       = $migrationRenderer;
		$this->diagnosticsRenderer     = $diagnosticsRenderer;
		$this->logsRenderer            = $logsRenderer;
		$this->syncJobsRenderer        = $syncJobsRenderer;
		$this->auditRenderer           = $auditRenderer;
		$this->logsListQueryParser     = $logsListQueryParser;
		$this->syncJobsListQueryParser = $syncJobsListQueryParser;
		$this->auditListQueryParser    = $auditListQueryParser;
	}

	/**
	 * Render the affilun-branded settings page hero.
	 */
	private function renderBrandHero(): void {
		$logoUrl = defined( 'AFFILUN_PLUGIN_URL' )
			? AFFILUN_PLUGIN_URL . 'assets/img/brand/logo-landscape-transparent.png'
			: '';

		echo '<section class="affilun-admin-hero" aria-labelledby="affilun-settings-title">';
		echo '<div class="affilun-admin-hero__content">';
		if ( $logoUrl !== '' ) {
			echo '<img class="affilun-admin-logo" src="' . esc_url( $logoUrl ) . '" alt="affilun / あふぃるん">';
		}
		echo '<p class="affilun-admin-kicker">' . esc_html( 'つなぐ・分析する・成長する' ) . '</p>';
		echo '<h1 id="affilun-settings-title" class="affilun-admin-title">' . esc_html( 'あふぃるん設定' ) . '</h1>';
		echo '</div>';
		echo '<div class="affilun-admin-hero__aside" aria-hidden="true"><div class="affilun-admin-palette">'
			. '<span></span><span></span><span></span><span></span><span></span></div></div>';
		echo '</section>';
	}

	/**
	 * Render the optional status-flash banner at the top of the
	 * page.
	 *
	 * @param string|null $statusFlash Status literal, or null
	 *                                 to suppress the banner.
	 */
	private function renderStatusFlash( ?string $statusFlash ): void {
		if ( $statusFlash === null || $statusFlash === '' ) {
			return;
		}

		switch ( $statusFlash ) {
			case 'saved':
				$this->renderNotice( 'success', '設定を保存しました。' );
				return;
			case 'error':
				$this->renderNotice( 'error', '設定の保存に失敗しました。' );
				return;
			case 'forbidden':
				$this->renderNotice( 'error', 'アクセス権限がありません。' );
				return;
			case 'invalid_nonce':
				$this->renderNotice( 'error', 'セキュリティトークンが不正です。もう一度お試しください。' );
				return;
			case 'amazon_resync_queued':
				$this->renderNotice( 'success', '既存のAmazon商品をCreators APIで再同期するジョブを登録しました。' );
				return;
			case 'amazon_resync_none':
				$this->renderNotice( 'success', '再同期対象のAmazon商品はありませんでした。' );
				return;
			case 'amazon_resync_error':
				$this->renderNotice( 'error', 'Amazon商品の再同期ジョブ登録に失敗しました。' );
				return;
			case 'external_affiliate_imported':
				$this->renderNotice( 'success', '既存のアフィリエイトリンクデータをaffilunに読み込みました。' );
				return;
			case 'external_affiliate_import_none':
				$this->renderNotice( 'success', '読み込みが必要な既存のアフィリエイトリンクデータはありませんでした。' );
				return;
			case 'external_affiliate_import_error':
				$this->renderNotice( 'error', '既存のアフィリエイトリンクデータの読み込みに失敗しました。' );
				return;
			case 'external_affiliate_notice_hidden':
				$this->renderNotice( 'success', '既存のアフィリエイトリンクデータの案内を非表示にしました。' );
				return;
			case 'yahoo_vc_invalid':
				$this->renderNotice( 'error', 'ValueCommerceのHTMLコードからsid/pidを抽出できませんでした。Yahoo!ショッピングの自由テキストリンクHTMLを貼り付けてください。' );
				return;

			default:
				return;
		}
	}

	/**
	 * Render a single WordPress admin notice.
	 *
	 * @param string $kind    Either `'success'` or `'error'`.
	 * @param string $message Human-readable message.
	 */
	private function renderNotice( string $kind, string $message ): void {
		$class = $kind === 'success' ? 'notice notice-success' : 'notice notice-error';
		echo '<div class="' . esc_attr( $class ) . '"><p>' . esc_html( $message ) . '</p></div>';
	}

	/**
	 * Render the neutral prompt for importing existing affiliate-link data.
	 *
	 * @param string $formAction admin-post endpoint URL.
	 */
	private function renderExternalAffiliateImportNotice( string $formAction ): void {
		if ( $this->externalAffiliateImporter === null || $this->isExternalAffiliateNoticeHidden() ) {
			return;
		}

		if ( $this->externalAffiliateImporter->detectedCount() < 1 ) {
			return;
		}

		echo '<div class="notice notice-info affilun-existing-affiliate-data-notice">';
		echo '<p>' . esc_html( '既存のアフィリエイトリンクデータを検出しました。' ) . '</p>';
		echo '<div class="affilun-inline-actions">';
		$this->renderExternalAffiliateNoticeForm(
			$formAction,
			self::EXTERNAL_AFFILIATE_IMPORT_ACTION,
			self::EXTERNAL_AFFILIATE_IMPORT_NONCE_ACTION,
			self::EXTERNAL_AFFILIATE_IMPORT_NONCE_FIELD,
			'affilunに読み込む',
			'button button-primary'
		);
		$this->renderExternalAffiliateNoticeForm(
			$formAction,
			self::EXTERNAL_AFFILIATE_DISMISS_ACTION,
			self::EXTERNAL_AFFILIATE_DISMISS_NONCE_ACTION,
			self::EXTERNAL_AFFILIATE_DISMISS_NONCE_FIELD,
			'このメッセージを非表示',
			'button button-secondary'
		);
		echo '</div>';
		echo '</div>';
	}

	/**
	 * Whether the operator hid the existing-data notice.
	 */
	private function isExternalAffiliateNoticeHidden(): bool {
		if ( $this->optionStore === null ) {
			return false;
		}

		return (bool) $this->optionStore->get( self::EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION, false );
	}

	/**
	 * Render one form button in the existing-data notice.
	 *
	 * @param string $formAction  admin-post endpoint URL.
	 * @param string $action      admin-post action.
	 * @param string $nonceAction Nonce action.
	 * @param string $nonceField  Nonce field name.
	 * @param string $label       Button label.
	 * @param string $buttonClass Button CSS class.
	 */
	private function renderExternalAffiliateNoticeForm(
		string $formAction,
		string $action,
		string $nonceAction,
		string $nonceField,
		string $label,
		string $buttonClass
	): void {
		echo '<form method="post" action="' . esc_url( $formAction ) . '" class="affilun-inline-form">';
		echo '<input type="hidden" name="action" value="' . esc_attr( $action ) . '">';
		( $this->nonceFieldEmitter )( $nonceAction, $nonceField );
		echo '<button type="submit" class="' . esc_attr( $buttonClass ) . '">';
		echo esc_html( $label );
		echo '</button>';
		echo '</form>';
	}

	/**
	 * Render the tab navigation for the Settings page.
	 */
	private function renderTabNavigation(): void {
		echo '<nav class="nav-tab-wrapper affilun-settings-tab-nav" role="tablist" aria-label="'
			. esc_attr( 'affilun 設定タブ' )
			. '">';

		$isFirst = true;
		foreach ( self::TAB_DEFINITIONS as $tabKey => $tab ) {
			$classes = $isFirst ? 'nav-tab nav-tab-active' : 'nav-tab';
			echo '<a href="#' . esc_attr( $tab['panel_id'] ) . '"'
				. ' id="' . esc_attr( $tab['panel_id'] . '-tab' ) . '"'
				. ' class="' . esc_attr( $classes ) . '"'
				. ' role="tab"'
				. ' aria-controls="' . esc_attr( $tab['panel_id'] ) . '"'
				. ' aria-selected="' . esc_attr( $isFirst ? 'true' : 'false' ) . '"'
				. ' data-affilun-tab-target="' . esc_attr( $tabKey ) . '"'
				. '>' . esc_html( $tab['label'] ) . '</a>';
			$isFirst = false;
		}

		echo '</nav>';
	}

	/**
	 * Open a tab panel wrapper.
	 *
	 * @param string $tabKey Tab key from {@see self::TAB_DEFINITIONS}.
	 */
	private function renderTabPanelOpen( string $tabKey ): void {
		if ( ! array_key_exists( $tabKey, self::TAB_DEFINITIONS ) ) {
			return;
		}

		$tab     = self::TAB_DEFINITIONS[ $tabKey ];
		$isFirst = $tabKey === array_key_first( self::TAB_DEFINITIONS );
		$classes = $isFirst
			? 'affilun-settings-tab-panel affilun-settings-tab-panel-active'
			: 'affilun-settings-tab-panel';

		echo '<section id="' . esc_attr( $tab['panel_id'] ) . '"'
			. ' class="' . esc_attr( $classes ) . '"'
			. ' role="tabpanel"'
			. ' aria-labelledby="' . esc_attr( $tab['panel_id'] . '-tab' ) . '"'
			. ' data-affilun-tab-panel="' . esc_attr( $tabKey ) . '"'
			. ' tabindex="0">';
	}

	/**
	 * Close the current tab panel wrapper.
	 */
	private function renderTabPanelClose(): void {
		echo '</section>';
	}

	/**
	 * Render a provider-specific tab panel.
	 *
	 * @param string         $providerKey Provider key.
	 * @param PluginSettings $settings    Current settings snapshot.
	 */
	private function renderProviderTabPanel( string $providerKey, PluginSettings $settings ): void {
		if ( ! isset( self::PROVIDER_SCHEMA[ $providerKey ], self::TAB_DEFINITIONS[ $providerKey ] ) ) {
			return;
		}

		$this->renderTabPanelOpen( $providerKey );
		$this->renderProviderBlock(
			$providerKey,
			self::PROVIDER_SCHEMA[ $providerKey ],
			$settings->provider( $providerKey )
		);
		$this->renderTabPanelClose();
	}

	/**
	 * Render the standalone Amazon bulk-resync form.
	 *
	 * This form is intentionally outside the settings-save form so
	 * pressing the button does not rotate credentials or alter any
	 * §13 settings; it only enqueues sync jobs for existing Amazon
	 * product rows.
	 *
	 * @param string $formAction admin-post.php URL.
	 */
	private function renderAmazonResyncForm( string $formAction ): void {
		echo '<hr>';
		echo '<h2>' . esc_html( 'Amazon 再同期' ) . '</h2>';
		echo '<p>'
			. esc_html(
				'Eligibility 獲得後、既存のAmazon商品をCreators APIで再取得するための同期ジョブを登録します。'
				. '価格・在庫・画像などの表示は同期完了後に更新されます。'
			)
			. '</p>';
		echo '<form method="post" action="' . esc_url( $formAction ) . '">';
		echo '<input type="hidden" name="action" value="'
			. esc_attr( SettingsFormToken::AMAZON_RESYNC_FORM_ACTION )
			. '">';
		( $this->nonceFieldEmitter )(
			SettingsFormToken::AMAZON_RESYNC_NONCE_ACTION,
			SettingsFormToken::AMAZON_RESYNC_NONCE_FIELD
		);
		echo '<p class="submit">';
		echo '<button type="submit" class="button button-secondary">';
		echo esc_html( '既存のAmazon商品をCreators APIで再同期' );
		echo '</button>';
		echo '</p>';
		echo '</form>';
	}

	/**
	 * Render the §13.5 Rendering section.
	 *
	 * @param PluginSettings $settings Current settings snapshot.
	 */
	private function renderRenderingSection( PluginSettings $settings ): void {
		$rendering = $settings->rendering();
		echo '<h2>' . esc_html( '表示設定' ) . '</h2>';
		echo '<p class="description">'
			. esc_html( '商品カードの初期デザインです。各ブロック側で個別に上書きできます。' )
			. '</p>';

		echo '<div class="affilun-rendering-settings-layout">';
		echo '<div class="affilun-rendering-settings-controls">';
		echo '<table class="form-table">';

		$this->renderSelectRow(
			'カードテンプレート',
			$this->fieldName( 'rendering', 'default_template' ),
			$rendering->defaultTemplate(),
			self::DEFAULT_TEMPLATE_OPTIONS
		);

		$this->renderSelectRow(
			'デザインプリセット',
			$this->fieldName( 'rendering', 'default_design_preset' ),
			$rendering->defaultDesignPreset(),
			self::DESIGN_PRESET_OPTIONS
		);

		$this->renderSelectRow(
			'カラーモード',
			$this->fieldName( 'rendering', 'default_color_scheme' ),
			$rendering->defaultColorScheme(),
			self::COLOR_SCHEME_OPTIONS
		);

		$this->renderSelectRow(
			'ボタン配置',
			$this->fieldName( 'rendering', 'default_button_layout' ),
			$rendering->defaultButtonLayout(),
			self::BUTTON_LAYOUT_OPTIONS
		);

		$this->renderSelectRow(
			'ボタン形状',
			$this->fieldName( 'rendering', 'default_button_shape' ),
			$rendering->defaultButtonShape(),
			self::BUTTON_SHAPE_OPTIONS
		);

		$this->renderCheckboxRow(
			'テーマによるテンプレート上書きを有効化',
			$this->fieldName( 'rendering', 'theme_override_enabled' ),
			$rendering->themeOverrideEnabled()
		);

		$this->renderCheckboxRow(
			'プロバイダーバッジをデフォルトで表示',
			$this->fieldName( 'rendering', 'show_provider_badge_by_default' ),
			$rendering->showProviderBadgeByDefault()
		);

		$this->renderCheckboxRow(
			'免責事項をデフォルトで表示',
			$this->fieldName( 'rendering', 'show_disclaimer_by_default' ),
			$rendering->showDisclaimerByDefault()
		);

		$this->renderCheckboxRow(
			'画像を遅延読み込み',
			$this->fieldName( 'rendering', 'lazy_load_images' ),
			$rendering->lazyLoadImages()
		);

		$this->renderSelectRow(
			'プレースホルダー画像ポリシー',
			$this->fieldName( 'rendering', 'placeholder_image_policy' ),
			$rendering->placeholderImagePolicy(),
			self::PLACEHOLDER_POLICIES
		);

		echo '</table>';
		echo '</div>';
		$this->renderRenderingPreview( $rendering, $settings );
		echo '</div>';
	}

	/**
	 * Render a live preview for the rendering default controls.
	 *
	 * @param RenderingDefaults $rendering Current rendering defaults.
	 * @param PluginSettings    $settings  Current settings snapshot.
	 */
	private function renderRenderingPreview( RenderingDefaults $rendering, PluginSettings $settings ): void {
		$rootClass        = $this->renderingPreviewRootClass( $rendering );
		$providers        = $this->renderingPreviewProviders( $settings );
		$enabledProviders = array_filter(
			$providers,
			static fn ( array $provider ): bool => (bool) $provider['enabled']
		);

		echo '<aside class="affilun-rendering-preview" data-affilun-rendering-preview data-affilun-preview-template="'
			. esc_attr( $rendering->defaultTemplate() )
			. '">';
		echo '<div class="affilun-rendering-preview__header">';
		echo '<h3>' . esc_html( 'プレビュー' ) . '</h3>';
		echo '<p>' . esc_html( '上の表示設定を変更すると、保存前にカードの見た目を確認できます。' ) . '</p>';
		echo '</div>';
		echo '<dl class="affilun-rendering-preview__meta">';
		echo '<div><dt>' . esc_html( 'テンプレート' ) . '</dt><dd data-affilun-preview-template-label>' . esc_html( $rendering->defaultTemplate() ) . '</dd></div>';
		echo '<div><dt>' . esc_html( 'プリセット' ) . '</dt><dd data-affilun-preview-design-label>' . esc_html( $rendering->defaultDesignPreset() ) . '</dd></div>';
		echo '<div><dt>' . esc_html( 'カラーモード' ) . '</dt><dd data-affilun-preview-color-scheme-label>' . esc_html( $rendering->defaultColorScheme() ) . '</dd></div>';
		echo '<div><dt>' . esc_html( 'ボタン' ) . '</dt><dd data-affilun-preview-button-label>' . esc_html( $rendering->defaultButtonLayout() . ' / ' . $rendering->defaultButtonShape() ) . '</dd></div>';
		echo '</dl>';
		$canonicalPreview = $this->renderCanonicalRenderingPreview( $rendering, $providers );
		if ( $canonicalPreview !== null ) {
			echo wp_kses_post( $canonicalPreview );
		} else {
			echo '<article class="' . esc_attr( $rootClass ) . '" data-affilun-preview-card data-affilun-card="settings-preview" style="'
				. esc_attr( $this->renderingPreviewProviderStyle( $providers ) )
				. '">';
			echo '<figure class="affilun-card__image"><span class="affilun-settings-preview-image" aria-hidden="true">affilun</span></figure>';
			echo '<div class="affilun-card__body">';
			echo '<header class="affilun-card__header">';
			echo '<span class="affilun-card__provider affilun-provider-amazon">' . esc_html( 'Amazon' ) . '</span>';
			echo '<h3 class="affilun-card__title"><span>' . esc_html( 'サンプル商品カードのタイトル' ) . '</span></h3>';
			echo '<p class="affilun-card__brand">' . esc_html( 'affilun サンプルブランド' ) . '</p>';
			echo '</header>';
			echo '<p class="affilun-card__description">' . esc_html( '商品説明が入ったときの余白、枠線、影、ボタン配置を確認するためのサンプルです。' ) . '</p>';
			echo '<p class="affilun-card__price"><span class="affilun-card__price-amount">' . esc_html( '¥12,800' ) . '</span><span class="affilun-card__price-observed-at">' . esc_html( '価格取得: 今日' ) . '</span></p>';
			echo '<p class="affilun-card__availability affilun-card__availability--in_stock">' . esc_html( '在庫あり' ) . '</p>';
			echo '<div class="affilun-card__buttons">';
			foreach ( $providers as $provider ) {
				$hidden = $provider['enabled'] ? '' : ' hidden';
				echo '<span class="affilun-button affilun-button-' . esc_attr( $provider['key'] ) . '" data-affilun-preview-provider="'
					. esc_attr( $provider['key'] ) . '"' . esc_attr( $hidden ) . '><span class="affilun-button__label">'
					. esc_html( $provider['label'] ) . '</span></span>';
			}
			echo '<p class="affilun-rendering-preview__no-providers" data-affilun-preview-no-providers'
				. ( $enabledProviders === array() ? '' : ' hidden' ) . '>'
				. esc_html( '有効化されているプロバイダーがありません。' )
				. '</p>';
			echo '</div>';
			echo '<footer class="affilun-card__disclaimers"><ul class="affilun-card__disclaimers-list">';
			echo '<li class="affilun-card__disclaimer">' . esc_html( '価格・在庫はリンク先でご確認ください。' ) . '</li>';
			echo '</ul></footer>';
			echo '</div>';
			echo '</article>';
		}
		echo '</aside>';
	}

	/**
	 * Render the Settings preview through the same card renderer used by
	 * frontend product-card blocks.
	 *
	 * @param RenderingDefaults                                                                $rendering Current rendering defaults.
	 * @param list<array{key:string,label:string,enabled:bool,color:string,text_color:string}> $providers Provider rows.
	 */
	private function renderCanonicalRenderingPreview( RenderingDefaults $rendering, array $providers ): ?string {
		if ( $this->previewViewModelFactory === null || $this->previewRenderer === null ) {
			return null;
		}

		try {
			$template = $rendering->defaultTemplate();
			$meta     = MetaFlags::defaults()
				->withShowDescription( true )
				->withShowProviderBadge( $rendering->showProviderBadgeByDefault() );
			$config   = new EmbedConfigInput(
				$template,
				array(),
				$meta,
				$this->renderingPreviewExtraRootClass( $rendering ),
				false
			);
			$context  = RenderContext::frontend( $template );
			$snapshot = $this->buildRenderingPreviewSnapshot();

			$viewModel = $this->previewViewModelFactory->build( $snapshot, $config, $context, array() );
			if ( $viewModel instanceof ProductCardViewModel ) {
				$viewModel = $viewModel->withButtons( $this->buildRenderingPreviewButtons( $providers, $snapshot->title() ) );
			}

			$html = $this->previewRenderer->render( $viewModel, $context );
			return $this->decorateRenderingPreviewHtml( $html, $providers );
		} catch ( Throwable $unused ) {
			unset( $unused );
			return null;
		}
	}

	/**
	 * Build a deterministic sample product snapshot for the Settings preview.
	 */
	private function buildRenderingPreviewSnapshot(): ProductSnapshot {
		$now      = new DateTimeImmutable( '2026-01-01 00:00:00', new DateTimeZone( 'UTC' ) );
		$imageUrl = defined( 'AFFILUN_PLUGIN_URL' )
			? (string) constant( 'AFFILUN_PLUGIN_URL' ) . 'assets/img/brand/logo-square.svg'
			: 'https://affilun.st/wp-content/plugins/affilun/assets/img/brand/logo-square.svg';

		return new ProductSnapshot(
			'amazon',
			new ProviderProductId( 'amazon', 'settings-preview-sample' ),
			'サンプル商品カードのタイトル',
			'affilun サンプルブランド',
			'商品説明が入ったときの余白、枠線、影、ボタン配置を確認するためのサンプルです。',
			'https://affilun.st/sample-product',
			null,
			$imageUrl,
			array(),
			new PriceInfo( '12800', 'JPY', 'サンプル価格', $now, true, true, null ),
			new AvailabilityInfo( AvailabilityInfo::STATUS_IN_STOCK, '在庫あり', true ),
			null,
			null,
			array( 'settings-preview' ),
			array(),
			$now,
			array()
		);
	}

	/**
	 * Build preview buttons for every provider so the Settings JS can
	 * show/hide them as the operator toggles provider enablement.
	 *
	 * @param list<array{key:string,label:string,enabled:bool,color:string,text_color:string}> $providers Provider rows.
	 * @param string                                                                           $title Sample product title.
	 *
	 * @return list<ButtonView>
	 */
	private function buildRenderingPreviewButtons( array $providers, string $title ): array {
		$buttons = array();
		foreach ( $providers as $provider ) {
			$url = $this->renderingPreviewProviderUrl( $provider['key'] );
			if ( $url === null ) {
				continue;
			}
			$buttons[] = new ButtonView(
				$provider['key'],
				$provider['label'],
				$url,
				ButtonView::TARGET_BLANK,
				'nofollow sponsored noopener noreferrer',
				'affilun-button affilun-button-' . $provider['key'],
				sprintf( '%s を %s で見る', $title, $provider['label'] )
			);
		}
		return $buttons;
	}

	/**
	 * Add Settings-preview data attributes to canonical renderer HTML.
	 *
	 * @param string                                                                           $html Rendered card HTML.
	 * @param list<array{key:string,label:string,enabled:bool,color:string,text_color:string}> $providers Provider rows.
	 */
	private function decorateRenderingPreviewHtml( string $html, array $providers ): string {
		$style = $this->renderingPreviewProviderStyle( $providers );
		$html  = preg_replace(
			'/<article\\b/',
			'<article data-affilun-preview-card style="' . esc_attr( $style ) . '"',
			$html,
			1
		);
		$html  = is_string( $html ) ? $html : '';

		foreach ( $providers as $provider ) {
			$hidden  = $provider['enabled'] ? '' : ' hidden';
			$pattern = '/<a class="([^"]*\\baffilun-button-' . preg_quote( $provider['key'], '/' ) . '\\b[^"]*)"/';
			$html    = preg_replace(
				$pattern,
				'<a class="$1" data-affilun-preview-provider="' . esc_attr( $provider['key'] ) . '"' . $hidden,
				$html,
				1
			);
			$html    = is_string( $html ) ? $html : '';
		}

		return $html;
	}

	/**
	 * Return a provider-native sample URL for Settings preview buttons.
	 *
	 * @param string $providerKey Provider key.
	 */
	private function renderingPreviewProviderUrl( string $providerKey ): ?string {
		return match ( $providerKey ) {
			'amazon' => 'https://www.amazon.co.jp/s?k=affilun',
			'rakuten' => 'https://search.rakuten.co.jp/search/mall/affilun/',
			'yahoo_shopping' => 'https://shopping.yahoo.co.jp/search?p=affilun',
			'mercari' => 'https://jp.mercari.com/search?keyword=affilun',
			default => null,
		};
	}

	/**
	 * Return provider rows used by the rendering preview.
	 *
	 * @param PluginSettings $settings Current settings snapshot.
	 *
	 * @return list<array{key:string,label:string,enabled:bool,color:string,text_color:string}>
	 */
	private function renderingPreviewProviders( PluginSettings $settings ): array {
		$rows = array();
		foreach ( self::PROVIDER_SCHEMA as $key => $schema ) {
			$current   = $settings->provider( $key );
			$color     = $current !== null ? (string) $current->secret( 'button_color' ) : '';
			$textColor = $current !== null ? (string) $current->secret( 'button_text_color' ) : '';
			$rows[]    = array(
				'key'        => $key,
				'label'      => $this->renderingPreviewProviderButtonLabel( $key, $schema['label'] ),
				'enabled'    => $current !== null && $current->enabled(),
				'color'      => ProviderCredentials::normalizeButtonColor( $key, $color ),
				'text_color' => ProviderCredentials::normalizeButtonTextColor( $textColor ),
			);
		}
		return $rows;
	}

	/**
	 * Build inline CSS variables for provider preview button colors.
	 *
	 * @param list<array{key:string,label:string,enabled:bool,color:string,text_color:string}> $providers Provider rows.
	 */
	private function renderingPreviewProviderStyle( array $providers ): string {
		$style = '';
		foreach ( $providers as $provider ) {
			$style .= ProductCardVisualSettings::providerButtonColorDeclaration(
				$provider['key'],
				$provider['color'],
				$provider['text_color']
			);
		}
		return $style;
	}

	/**
	 * Return the provider preview button label.
	 *
	 * @param string $key      Provider key.
	 * @param string $fallback Fallback provider label.
	 */
	private function renderingPreviewProviderButtonLabel( string $key, string $fallback ): string {
		return match ( $key ) {
			'amazon' => 'Amazon',
			'rakuten' => '楽天市場',
			'yahoo_shopping' => 'Yahoo!ショッピング',
			'mercari' => 'メルカリ',
			default => $fallback,
		};
	}

	/**
	 * Compose the root classes used by the fallback rendering preview card.
	 *
	 * @param RenderingDefaults $rendering Current rendering defaults.
	 */
	private function renderingPreviewRootClass( RenderingDefaults $rendering ): string {
		$extra = $this->renderingPreviewExtraRootClass( $rendering );
		return trim( 'affilun-card affilun-card-' . $rendering->defaultTemplate() . ' affilun-provider-amazon ' . $extra );
	}

	/**
	 * Compose the visual-modifier classes passed to the canonical
	 * card ViewModel factory.
	 *
	 * @param RenderingDefaults $rendering Current rendering defaults.
	 */
	private function renderingPreviewExtraRootClass( RenderingDefaults $rendering ): string {
		return ProductCardVisualSettings::rootClasses( $rendering );
	}

	/**
	 * Render the §13.6 Links section.
	 *
	 * @param LinkDefaults $links Current link defaults.
	 */
	private function renderLinksSection( LinkDefaults $links ): void {
		echo '<h2>' . esc_html( 'リンク設定' ) . '</h2>';
		echo '<table class="form-table">';

		$this->renderCheckboxRow(
			'新しいタブで開く',
			$this->fieldName( 'links', 'open_in_new_tab_default' ),
			$links->openInNewTabDefault()
		);

		$this->renderCheckboxRow(
			'rel="nofollow" を追加',
			$this->fieldName( 'links', 'add_nofollow' ),
			$links->addNofollow()
		);

		$this->renderCheckboxRow(
			'rel="sponsored" を追加',
			$this->fieldName( 'links', 'add_sponsored' ),
			$links->addSponsored()
		);

		$this->renderCheckboxRow(
			'rel="noopener" を追加',
			$this->fieldName( 'links', 'add_noopener' ),
			$links->addNoopener()
		);

		$this->renderCheckboxRow(
			'rel="noreferrer" を追加',
			$this->fieldName( 'links', 'add_noreferrer' ),
			$links->addNoreferrer()
		);

		$this->renderCheckboxRow(
			'トラッキングをデフォルトで有効化',
			$this->fieldName( 'links', 'tracking_enabled_default' ),
			$links->trackingEnabledDefault()
		);

		echo '</table>';
	}

	/**
	 * Render the §13.7 Sync section.
	 *
	 * @param SyncSettings $sync Current sync settings.
	 */
	private function renderSyncSection( SyncSettings $sync ): void {
		echo '<h2>' . esc_html( '同期 (Sync)' ) . '</h2>';
		echo '<table class="form-table">';

		$this->renderCheckboxRow(
			'同期を有効化',
			$this->fieldName( 'sync', 'enabled' ),
			$sync->enabled()
		);

		$this->renderCheckboxRow(
			'スケジュール同期を有効化',
			$this->fieldName( 'sync', 'scheduled_sync_enabled' ),
			$sync->scheduledSyncEnabled()
		);

		$this->renderNumberRow(
			'フレッシュとして扱う期間 (秒)',
			$this->fieldName( 'sync', 'fresh_ttl_seconds' ),
			$sync->freshTtlSeconds()
		);

		$this->renderNumberRow(
			'ステールとして扱う期間 (秒)',
			$this->fieldName( 'sync', 'stale_ttl_seconds' ),
			$sync->staleTtlSeconds()
		);

		$this->renderNumberRow(
			'期限切れとみなす期間 (秒)',
			$this->fieldName( 'sync', 'expired_ttl_seconds' ),
			$sync->expiredTtlSeconds()
		);

		$this->renderNumberRow(
			'同時実行ジョブ最大数',
			$this->fieldName( 'sync', 'max_concurrent_jobs' ),
			$sync->maxConcurrentJobs()
		);

		$this->renderNumberRow(
			'バッチサイズ',
			$this->fieldName( 'sync', 'batch_size' ),
			$sync->batchSize()
		);

		$this->renderSelectRow(
			'リトライポリシー',
			$this->fieldName( 'sync', 'retry_policy_preset' ),
			$sync->retryPolicyPreset(),
			self::RETRY_POLICY_PRESETS
		);

		echo '</table>';
	}

	/**
	 * Render the §13.8 Cache section.
	 *
	 * @param CacheSettings $cache Current cache settings.
	 */
	private function renderCacheSection( CacheSettings $cache ): void {
		echo '<h2>' . esc_html( 'キャッシュ設定' ) . '</h2>';
		echo '<table class="form-table">';

		$this->renderCheckboxRow(
			'検索キャッシュを有効化',
			$this->fieldName( 'cache', 'search_cache_enabled' ),
			$cache->searchCacheEnabled()
		);

		$this->renderNumberRow(
			'検索キャッシュの有効期間 (秒)',
			$this->fieldName( 'cache', 'search_cache_ttl_seconds' ),
			$cache->searchCacheTtlSeconds()
		);

		$this->renderNumberRow(
			'ルックアップキャッシュの有効期間 (秒)',
			$this->fieldName( 'cache', 'lookup_cache_ttl_seconds' ),
			$cache->lookupCacheTtlSeconds()
		);

		$this->renderCheckboxRow(
			'描画キャッシュを有効化',
			$this->fieldName( 'cache', 'render_cache_enabled' ),
			$cache->renderCacheEnabled()
		);

		$this->renderNumberRow(
			'描画キャッシュの有効期間 (秒)',
			$this->fieldName( 'cache', 'render_cache_ttl_seconds' ),
			$cache->renderCacheTtlSeconds()
		);

		$this->renderNumberRow(
			'プロバイダーヘルスキャッシュの有効期間 (秒)',
			$this->fieldName( 'cache', 'provider_health_cache_ttl_seconds' ),
			$cache->providerHealthCacheTtlSeconds()
		);

		echo '</table>';
	}

	/**
	 * Render a single provider's section.
	 *
	 * @param string                   $providerKey Provider key.
	 * @param array<string, mixed>     $schema      Per-provider schema entry.
	 * @param ProviderCredentials|null $current     Currently persisted credentials, or null.
	 *
	 * @phpstan-param array{label: string, secrets: array<string, string>} $schema
	 */
	private function renderProviderBlock(
		string $providerKey,
		array $schema,
		?ProviderCredentials $current
	): void {
		$this->renderSetupGuideLink( $providerKey );
		if ( $providerKey === 'yahoo_shopping' ) {
			echo '<p class="description">'
				. esc_html( 'Yahoo!ショッピングの報酬化には、Yahoo Developer Client ID に加えて、ValueCommerce の sid / pid をこのタブで設定します。' )
				. '</p>';
		}
		if ( $providerKey === 'rakuten' ) {
			echo '<p class="description">'
				. esc_html( '2026年版の楽天ウェブサービスで再登録した Application ID と Access Key を設定してください。楽天の「許可されたWebサイト」には、このWordPressサイトのURLを登録してください。Affiliate ID は成果報酬リンクの取得に使用します。' )
				. '</p>';
		}

		echo '<table class="form-table">';

		$this->renderCheckboxRow(
			'有効化',
			$this->fieldName( 'providers', $providerKey, 'enabled' ),
			$current !== null && $current->enabled()
		);

		foreach ( $schema['secrets'] as $secretKey => $secretLabel ) {
			if ( $providerKey === 'yahoo_shopping'
				&& in_array( $secretKey, array( 'affiliate_sid', 'affiliate_pid' ), true )
				&& ! $this->hasYahooValueCommerceCredentials( $current )
			) {
				continue;
			}

			$plain      = $current !== null ? (string) $current->secret( $secretKey ) : '';
			$name       = $this->fieldName( 'providers', $providerKey, $secretKey );
			$inputValue = $this->providerInputDefaultValue( $providerKey, $secretKey, $plain );

			echo '<tr>';
			echo '<th scope="row">' . esc_html( $secretLabel ) . '</th>';
			echo '<td>';
			if ( $plain !== '' ) {
				echo '<span class="affilun-current-secret">'
					. esc_html( '現在の値: ' )
					. esc_html( $plain )
					. '</span><br>';
			} else {
				echo '<span class="affilun-current-secret">' . esc_html( '現在の値: (未設定)' ) . '</span><br>';
			}
			// Admin-tier (`manage_options`) Settings page is the only
			// place these values are surfaced verbatim. The rotating-
			// input contract (§6.3) still applies: blank submission
			// keeps the current persisted value. Users explicitly
			// requested plain display so the saved credentials can be
			// confirmed against the upstream Associates / RMS / DG
			// console; treat the admin session accordingly.
			echo '<input type="text" name="' . esc_attr( $name ) . '"'
				. ' form="' . esc_attr( self::SETTINGS_FORM_ID ) . '"'
				. ' value="' . esc_attr( $inputValue ) . '" autocomplete="off" spellcheck="false"'
				. ' class="regular-text" placeholder="変更しない場合は空欄のまま"'
				. '>';
			echo '</td>';
			echo '</tr>';
		}

		if ( $providerKey === 'yahoo_shopping' && ! $this->hasYahooValueCommerceCredentials( $current ) ) {
			$this->renderYahooValueCommercePasteRow();
		}

		$this->renderProviderButtonColorRow( $providerKey, $current );
		$this->renderProviderButtonTextColorRow( $providerKey, $current );

		echo '</table>';
	}

	/**
	 * Render the Yahoo ValueCommerce free-text-link paste helper.
	 */
	private function renderYahooValueCommercePasteRow(): void {
		$fieldName = $this->fieldName( 'providers', 'yahoo_shopping', 'valuecommerce_html' );
		echo '<tr>';
		echo '<th scope="row"><label for="affilun-yahoo-valuecommerce-html">'
			. esc_html( 'ValueCommerce HTMLコード' )
			. '</label></th>';
		echo '<td>';
		echo '<textarea id="affilun-yahoo-valuecommerce-html"'
			. ' name="' . esc_attr( $fieldName ) . '"'
			. ' form="' . esc_attr( self::SETTINGS_FORM_ID ) . '"'
			. ' class="large-text code" rows="8" autocomplete="off" spellcheck="false"'
			. ' placeholder="' . esc_attr( '<a href="http://ck.jp.ap.valuecommerce.com/servlet/referral?sid=...&pid=...">自由テキスト</a>' ) . '"></textarea>';
		echo '<p class="description">'
			. esc_html( 'ValueCommerceでYahoo!ショッピングの自由テキストリンクHTMLを作成し、そのHTMLコードまたは referral URL を貼り付けて保存してください。affilunはsid/pidのみを抽出し、貼り付けたHTMLは保存しません。' )
			. '</p>';
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * Report whether Yahoo ValueCommerce sid / pid are both configured.
	 *
	 * @param ProviderCredentials|null $current Current Yahoo credentials.
	 */
	private function hasYahooValueCommerceCredentials( ?ProviderCredentials $current ): bool {
		return $current !== null
			&& trim( (string) $current->secret( 'affiliate_sid' ) ) !== ''
			&& trim( (string) $current->secret( 'affiliate_pid' ) ) !== '';
	}

	/**
	 * Render a provider-specific affiliate-button color picker.
	 *
	 * @param string                   $providerKey Provider key.
	 * @param ProviderCredentials|null $current     Current credentials.
	 */
	private function renderProviderButtonColorRow( string $providerKey, ?ProviderCredentials $current ): void {
		$defaultColor = ProviderCredentials::defaultButtonColor( $providerKey );
		$currentColor = $current !== null ? (string) $current->secret( 'button_color' ) : '';
		$value        = ProviderCredentials::normalizeButtonColor( $providerKey, $currentColor );
		$fieldName    = $this->fieldName( 'providers', $providerKey, 'button_color' );
		$inputId      = 'affilun-provider-' . str_replace( '_', '-', $providerKey ) . '-button-color';

		echo '<tr class="affilun-provider-button-color-row">';
		echo '<th scope="row"><label for="' . esc_attr( $inputId ) . '">'
			. esc_html( 'ボタンカラー' )
			. '</label></th>';
		echo '<td>';
		echo '<input type="color" id="' . esc_attr( $inputId ) . '"'
			. ' name="' . esc_attr( $fieldName ) . '"'
			. ' form="' . esc_attr( self::SETTINGS_FORM_ID ) . '"'
			. ' value="' . esc_attr( $value ) . '"'
			. ' data-affilun-default-color="' . esc_attr( $defaultColor ) . '"> ';
		echo '<button type="button" class="button button-secondary" data-affilun-reset-color="'
			. esc_attr( $inputId ) . '">'
			. esc_html( 'デフォルトに戻す' )
			. '</button>';
		echo '<p class="description">'
			. esc_html( '商品カードに表示されるこのプロバイダーのアフィリエイトボタン背景色です。' )
			. '</p>';
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * Render a provider-specific affiliate-button text color selector.
	 *
	 * @param string                   $providerKey Provider key.
	 * @param ProviderCredentials|null $current     Current credentials.
	 */
	private function renderProviderButtonTextColorRow( string $providerKey, ?ProviderCredentials $current ): void {
		$currentValue = $current !== null ? (string) $current->secret( 'button_text_color' ) : '';
		$value        = ProviderCredentials::normalizeButtonTextColor( $currentValue );
		$fieldName    = $this->fieldName( 'providers', $providerKey, 'button_text_color' );
		$inputId      = 'affilun-provider-' . str_replace( '_', '-', $providerKey ) . '-button-text-color';
		$options      = array(
			ProviderCredentials::BUTTON_TEXT_COLOR_AUTO  => '自動（APCA）',
			ProviderCredentials::BUTTON_TEXT_COLOR_WHITE => '白',
			ProviderCredentials::BUTTON_TEXT_COLOR_BLACK => '黒',
		);

		echo '<tr class="affilun-provider-button-text-color-row">';
		echo '<th scope="row"><label for="' . esc_attr( $inputId ) . '">'
			. esc_html( 'ボタン文字カラー' )
			. '</label></th>';
		echo '<td>';
		echo '<select id="' . esc_attr( $inputId ) . '"'
			. ' name="' . esc_attr( $fieldName ) . '"'
			. ' form="' . esc_attr( self::SETTINGS_FORM_ID ) . '">';
		foreach ( $options as $optionValue => $label ) {
			$selected = $value === $optionValue ? ' selected' : '';
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals (' selected' or ''); no user input is interpolated.
			echo '<option value="' . esc_attr( $optionValue ) . '"' . $selected . '>'
				. esc_html( $label )
				. '</option>';
		}
		echo '</select>';
		echo '<p class="description">'
			. esc_html( '自動は APCA ベースで黒または白を選びます。ブランド感を優先したい場合は白/黒を固定できます。' )
			. '</p>';
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * Return the initial input value for provider fields that have
	 * a product-level default. Most credential fields still use the
	 * rotating-input contract (`value=""` means keep current), but
	 * Amazon's version is non-secret and should default to the
	 * Japan-focused Creators API v3.3 value on first setup.
	 *
	 * @param string $providerKey Provider key.
	 * @param string $secretKey   Secret-field key.
	 * @param string $plain       Currently persisted value, if any.
	 */
	private function providerInputDefaultValue( string $providerKey, string $secretKey, string $plain ): string {
		if ( $providerKey === 'amazon' && $secretKey === 'version' && trim( $plain ) === '' ) {
			return ProviderCredentials::AMAZON_DEFAULT_VERSION;
		}

		return '';
	}



	/**
	 * Render advanced ASP affiliate-routing settings.
	 */
	private function renderAffiliateRoutingSection(): void {
		$this->renderSetupGuideLink( 'asp' );

		if ( $this->affiliateRoutingRepository === null ) {
			echo '<p class="description">' . esc_html( 'ASPリンク設定リポジトリが利用できません。' ) . '</p>';
			return;
		}

		$templates = $this->affiliateRoutingRepository->loadRaw();
		echo '<p class="description">'
			. esc_html( '通常は各プロバイダーのタブでアフィリエイト設定を行います。ここは独自ASPや移行用URLテンプレートを使う上級者向け設定です。' )
			. '</p>';
		echo '<table class="form-table" role="presentation">';
		$this->renderCheckboxRow(
			'有効化',
			$this->fieldName( 'affiliate_routing', 'enabled' ),
			$this->affiliateRoutingRepository->enabled()
		);
		foreach ( OptionStoreAffiliateRoutingRepository::PROVIDERS as $provider ) {
			$value = $templates[ $provider ] ?? '';
			echo '<tr><th scope="row"><label for="affilun-routing-' . esc_attr( $provider ) . '">'
				. esc_html( $provider ) . '</label></th><td>';
			echo '<input class="large-text" type="url" id="affilun-routing-' . esc_attr( $provider ) . '"'
				. ' name="' . esc_attr( $this->fieldName( 'affiliate_routing', 'templates', $provider ) ) . '"'
				. ' form="' . esc_attr( self::SETTINGS_FORM_ID ) . '"'
				. ' value="' . esc_attr( $value ) . '" placeholder="https://example.test/route?url={encoded_url}">';
			echo '</td></tr>';
		}
		echo '</table>';
	}

	/**
	 * Render affilun Cloud settings.
	 *
	 * @param CloudSettings $cloud Current Cloud settings.
	 */
	private function renderCloudSection( CloudSettings $cloud ): void {
		$siteUrl            = self::defaultSiteUrl();
		$siteName           = self::defaultSiteName();
		$adminEmail         = self::defaultAdminEmail();
		$cloudConfigured    = $cloud->isConfigured();
		$cloudAccountUrl    = self::cloudAccountUrlFromEndpoint( $cloud->endpoint() );
		$cloudPlansUrl      = self::resolveRestUrl( 'affilun/v1/settings/cloud/plans' );
		$checkoutUrl        = self::resolveRestUrl( 'affilun/v1/settings/cloud/checkout-session' );
		$provisionUrl       = self::resolveRestUrl( 'affilun/v1/settings/cloud/provision-site' );
		$connectionEmailUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/connection-email' );
		$statusUrl          = self::resolveRestUrl( 'affilun/v1/settings/cloud/status' );
		$connectionUrl      = self::resolveRestUrl( 'affilun/v1/settings/cloud/connection-token' );
		$credentialsUrl     = self::resolveRestUrl( 'affilun/v1/settings/cloud/credentials' );
		$restNonce          = function_exists( 'wp_create_nonce' ) ? (string) wp_create_nonce( 'wp_rest' ) : '';
		$remoteStatus       = $cloudConfigured ? null : self::remoteCloudStatus( $cloud, $siteUrl );
		$remoteCloudAccess  = is_array( $remoteStatus )
			&& (bool) ( $remoteStatus['cloud_access'] ?? false )
			&& (bool) ( $remoteStatus['connection_required'] ?? false );
		$remotePlan         = is_array( $remoteStatus ) && is_scalar( $remoteStatus['plan'] ?? null )
			? (string) $remoteStatus['plan']
			: '';

		$this->renderHiddenInput(
			$this->fieldName( 'cloud', 'enabled' ),
			$cloud->enabled() ? '1' : '0',
			'affilun-cloud-enabled'
		);
		$this->renderHiddenInput( $this->fieldName( 'cloud', 'endpoint' ), $cloud->endpoint(), 'affilun-cloud-endpoint' );
		$this->renderHiddenInput( $this->fieldName( 'cloud', 'site_id' ), $cloud->siteId(), 'affilun-cloud-site-id' );
		$this->renderHiddenInput( $this->fieldName( 'cloud', 'api_key' ), '', 'affilun-cloud-api-key' );
		$this->renderHiddenInput( $this->fieldName( 'cloud', 'plan' ), $cloud->plan(), 'affilun-cloud-plan' );

		echo '<div class="affilun-cloud-onboarding" data-affilun-cloud-onboarding'
			. ' data-affilun-cloud-configured="' . esc_attr( $cloudConfigured ? '1' : '0' ) . '"'
			. ' data-affilun-cloud-plans-url="' . esc_url( $cloudPlansUrl ) . '"'
			. ' data-affilun-cloud-checkout-url="' . esc_url( $checkoutUrl ) . '"'
			. ' data-affilun-cloud-provision-url="' . esc_url( $provisionUrl ) . '"'
			. ' data-affilun-cloud-connection-email-url="' . esc_url( $connectionEmailUrl ) . '"'
			. ' data-affilun-cloud-status-url="' . esc_url( $statusUrl ) . '"'
			. ' data-affilun-cloud-connection-url="' . esc_url( $connectionUrl ) . '"'
			. ' data-affilun-cloud-credentials-url="' . esc_url( $credentialsUrl ) . '"'
			. ' data-affilun-cloud-remote-access="' . esc_attr( $remoteCloudAccess ? '1' : '0' ) . '"'
			. ' data-affilun-rest-nonce="' . esc_attr( $restNonce ) . '">';
		echo '<div class="notice notice-info inline"><p id="affilun-cloud-onboarding-message">';
		if ( $cloudConfigured ) {
			echo esc_html( 'affilun Cloud は設定済みです。現在のプラン: ' . ( $cloud->plan() !== '' ? $cloud->plan() : '不明' ) );
		} elseif ( $remoteCloudAccess ) {
			echo esc_html( 'affilun Cloud の契約は確認済みです。プラグインから利用するには、メール認証してCloudアカウントに接続してください。現在のプラン: ' . ( $remotePlan !== '' ? $remotePlan : '不明' ) );
		} else {
			echo esc_html( 'affilun Cloud は未設定です。最初に、下の入力内容を確認して、メール認証してください。' );
			echo '<br><strong class="affilun-cloud-free-trial-notice">'
				. esc_html( 'affilun Cloudは初回登録に限り30日間は無料でお試しいただけます。' )
				. '</strong>';
		}
		echo '</p></div>';

		if ( $cloudConfigured ) {
			echo '<p id="affilun-cloud-start-row">';
			echo '<a class="button button-primary" id="affilun-cloud-account-connect-button" href="'
				. esc_url( $cloudAccountUrl )
				. '" target="_blank" rel="noopener noreferrer">'
				. esc_html( 'Cloudアカウントページを開く' )
				. '</a> ';
			echo '<span class="description">'
				. esc_html( '契約内容の変更・解約・アカウント設定は affilun Cloud アカウントページで行います。' )
				. '</span></p>';
			echo '<hr>';
			$this->renderCloudArticleDraftDefaults( $cloud );
		} else {
			echo '<p id="affilun-cloud-start-row" hidden aria-hidden="true">';
			echo '<button type="button" class="button" id="affilun-cloud-start-button" hidden aria-hidden="true">'
				. esc_html( '簡易購入フローを使う' )
				. '</button></p>';
		}

		echo '<div id="affilun-cloud-wizard" class="affilun-cloud-wizard"' . ( $cloudConfigured ? ' hidden' : '' ) . '>';
		echo '<h3>' . esc_html( 'affilun Cloud 利用開始ウィザード' ) . '</h3>';

		echo '<table class="form-table" role="presentation">';
		echo '<tr><th scope="row"><label for="affilun-cloud-site-url">' . esc_html( 'サイトURL' ) . '</label></th><td>';
		echo '<input type="url" id="affilun-cloud-site-url" value="' . esc_attr( $siteUrl ) . '"'
			. ' class="regular-text" autocomplete="url">';
		echo '</td></tr>';
		echo '<tr><th scope="row"><label for="affilun-cloud-email">' . esc_html( 'メールアドレス' ) . '</label></th><td>';
		echo '<input type="email" id="affilun-cloud-email" value="' . esc_attr( $adminEmail ) . '"'
			. ' class="regular-text" autocomplete="email">';
		echo '<p class="description">' . esc_html( '管理者メールアドレスを初期値として表示しています。' ) . '</p>';
		echo '</td></tr>';
		echo '<tr><th scope="row"><label for="affilun-cloud-site-name">' . esc_html( 'サイト名' ) . '</label></th><td>';
		echo '<input type="text" id="affilun-cloud-site-name" value="' . esc_attr( $siteName ) . '" class="regular-text">';
		echo '</td></tr>';
		echo '</table>';

		echo '<div class="affilun-cloud-plan-summary" id="affilun-cloud-plan-summary" aria-live="polite">';
		echo '<h4>' . esc_html( '提供予定の機能' ) . '</h4>';
		echo '<ul style="margin-left:1.4em;list-style:disc;">';
		echo '<li><strong>' . esc_html( 'Personal' ) . '</strong> — ' . esc_html( '1サイトまで。AIキーワード抽出、AI記事下書き生成、リンクヘルスチェック、キャンペーン提案、売れ筋アフィリエイト候補、利用状況の確認' ) . '</li>';
		echo '<li><strong>' . esc_html( 'Growth（近日リリース）' ) . '</strong> — ' . esc_html( '5サイトまで。Personal の全機能、分析レコメンド、分析イベント同期、スケジュール型リンクヘルス' ) . '</li>';
		echo '<li><strong>' . esc_html( 'Agency（近日リリース）' ) . '</strong> — ' . esc_html( 'サイト数制限なし。Growth の全機能、より高い利用枠、複数サイト管理' ) . '</li>';
		echo '</ul>';
		echo '<p class="description" id="affilun-cloud-free-trial-summary">'
			. esc_html(
				$remoteCloudAccess
					? 'このサイトは既にCloud側で契約済みです。新規契約ではなく、メール認証後にCloudアカウントへログインして接続を完了してください。'
					: '契約プランと月額・年額の選択は、メール認証後の affilun Cloud アカウントページで行います。'
			)
			. '</p>';
		echo '</div>';

		echo '<p><button type="button" class="button button-primary" id="affilun-cloud-purchase-button"'
			. ' data-affilun-cloud-base-url="' . esc_url( $cloud->endpoint() ) . '"'
			. ' data-affilun-cloud-configured="' . esc_attr( $cloud->isConfigured() ? '1' : '0' ) . '">'
			. esc_html( $remoteCloudAccess ? 'メール認証して既存契約に接続' : 'メール認証' )
			. '</button> ';
		echo '<span id="affilun-cloud-wizard-status" class="description"></span></p>';
		echo '</div>';
		echo '</div>';
	}

	/**
	 * Render operation tool links.
	 */
	private function renderOperationsSection(): void {
		$this->renderOperationSectionOpen( '移行 / スキャン', '既存記事から商品リンク設置候補を探し、移行候補を確認します。' );
		if ( $this->migrationRenderer !== null ) {
			$this->migrationRenderer->renderEmbedded( $this->operationRequest, 'affilun-settings', '#affilun-settings-tab-operations' );
		} else {
			$this->renderOperationUnavailable();
		}
		$this->renderOperationSectionClose();

		$this->renderOperationSectionOpen( '診断情報', '環境・設定・接続状態を確認します。' );
		if ( $this->diagnosticsRenderer !== null ) {
			$this->diagnosticsRenderer->renderEmbedded();
		} else {
			$this->renderOperationUnavailable();
		}
		$this->renderOperationSectionClose();

		$this->renderOperationSectionOpen( 'ログ', 'プラグインの動作ログを確認します。' );
		if ( $this->logsRenderer !== null && $this->logsListQueryParser !== null ) {
			$this->logsRenderer->renderEmbedded(
				$this->logsListQueryParser->parse( $this->operationRequest ),
				'affilun-settings',
				'#affilun-settings-tab-operations'
			);
		} else {
			$this->renderOperationUnavailable();
		}
		$this->renderOperationSectionClose();

		$this->renderOperationSectionOpen( '問題ジョブ', '同期や処理キューの失敗を確認します。' );
		if ( $this->syncJobsRenderer !== null && $this->syncJobsListQueryParser !== null ) {
			$this->syncJobsRenderer->renderEmbedded(
				$this->syncJobsListQueryParser->parse( $this->operationRequest ),
				'affilun-settings',
				'#affilun-settings-tab-operations'
			);
		} else {
			$this->renderOperationUnavailable();
		}
		$this->renderOperationSectionClose();

		$this->renderOperationSectionOpen( '監査イベント', '管理操作や重要イベントを確認します。' );
		if ( $this->auditRenderer !== null && $this->auditListQueryParser !== null ) {
			$this->auditRenderer->renderEmbedded(
				$this->auditListQueryParser->parse( $this->operationRequest ),
				'affilun-settings',
				'#affilun-settings-tab-operations'
			);
		} else {
			$this->renderOperationUnavailable();
		}
		$this->renderOperationSectionClose();
	}

	/**
	 * Open an Operations tab subsection.
	 *
	 * @param string $title       Section title.
	 * @param string $description Section description.
	 */
	private function renderOperationSectionOpen( string $title, string $description ): void {
		echo '<section class="affilun-operation-section">';
		echo '<h2>' . esc_html( $title ) . '</h2>';
		echo '<p class="description">' . esc_html( $description ) . '</p>';
	}

	/**
	 * Close an Operations tab subsection with a separator.
	 */
	private function renderOperationSectionClose(): void {
		echo '</section><hr class="affilun-operation-separator">';
	}

	/**
	 * Render a degraded message when an operation renderer is absent.
	 */
	private function renderOperationUnavailable(): void {
		echo '<p>' . esc_html( 'この運用ツールは現在利用できません。' ) . '</p>';
	}

	/**
	 * Render the §13.9 Advanced section.
	 *
	 * @param PluginSettings $settings Current settings snapshot.
	 */
	private function renderAdvancedSection( PluginSettings $settings ): void {
		echo '<h2>' . esc_html( 'デバッグ' ) . '</h2>';
		echo '<table class="form-table">';

		$this->renderCheckboxRow(
			'デバッグモードを有効化',
			$this->fieldName( 'debug_mode' ),
			$settings->debugMode()
		);

		echo '</table>';
	}

	/**
	 * Render a hidden setting input associated with the main settings form.
	 *
	 * @param string $name  HTML name attribute.
	 * @param string $value HTML value attribute.
	 * @param string $id    HTML id attribute.
	 */
	private function renderHiddenInput( string $name, string $value, string $id ): void {
		echo '<input type="hidden" id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '"'
			. ' form="' . esc_attr( self::SETTINGS_FORM_ID ) . '"'
			. ' value="' . esc_attr( $value ) . '">';
	}

	/**
	 * Render affilun Cloud article-draft defaults.
	 *
	 * @param CloudSettings $cloud Current Cloud settings.
	 */
	private function renderCloudArticleDraftDefaults( CloudSettings $cloud ): void {
		$tones          = array(
			'professional_blog' => 'プロらしいブログ調',
			'friendly'          => '親しみやすい紹介調',
			'review_comparison' => '比較レビュー調',
			'concise'           => '簡潔な要点整理',
		);
		$lengths        = array(
			'short'  => '短め',
			'medium' => '標準',
			'long'   => '長め',
		);
		$reviewPolicies = array(
			'product_url' => '商品URLを参考URLとして送信する',
			'none'        => '参考URLを送信しない',
		);

		echo '<div class="affilun-cloud-article-draft-defaults">';
		echo '<h3>' . esc_html( 'AI記事生成の設定' ) . '</h3>';
		echo '<p class="description">'
			. esc_html( '保存済み商品から記事下書きを生成するときの共通設定です。商品ごとの細かな指定は行わず、この設定を既定値として使います。' )
			. '</p>';
		echo '<table class="form-table" role="presentation">';

		$this->renderCloudSelectRow(
			'文体',
			$this->fieldName( 'cloud', 'article_draft_tone' ),
			'affilun-cloud-article-draft-tone',
			$cloud->articleDraftTone(),
			$tones
		);
		$this->renderCloudSelectRow(
			'長さ',
			$this->fieldName( 'cloud', 'article_draft_target_length' ),
			'affilun-cloud-article-draft-target-length',
			$cloud->articleDraftTargetLength(),
			$lengths
		);
		$this->renderCloudSelectRow(
			'参考URL',
			$this->fieldName( 'cloud', 'article_draft_review_source_policy' ),
			'affilun-cloud-article-draft-review-source-policy',
			$cloud->articleDraftReviewSourcePolicy(),
			$reviewPolicies
		);

		echo '</table>';
		echo '<p class="submit" style="margin-top:0;">';
		echo '<button type="submit" form="' . esc_attr( self::SETTINGS_FORM_ID ) . '" class="button button-primary">'
			. esc_html( '設定を保存' ) . '</button>';
		echo '</p>';
		echo '</div>';
	}

	/**
	 * Render a select row in the affilun Cloud tab.
	 *
	 * @param string               $label   Row label.
	 * @param string               $name    Field name.
	 * @param string               $id      Field id.
	 * @param string               $value   Current value.
	 * @param array<string,string> $options Option labels by value.
	 */
	private function renderCloudSelectRow( string $label, string $name, string $id, string $value, array $options ): void {
		echo '<tr><th scope="row"><label for="' . esc_attr( $id ) . '">'
			. esc_html( $label ) . '</label></th><td>';
		echo '<select id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" form="'
			. esc_attr( self::SETTINGS_FORM_ID ) . '">';
		foreach ( $options as $optionValue => $optionLabel ) {
			$selected = $optionValue === $value ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $optionValue ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is a hard-coded selected attribute or empty string.
				. $selected
				. '>' . esc_html( $optionLabel ) . '</option>';
		}
		echo '</select></td></tr>';
	}

	/**
	 * Default site URL for the affilun Cloud onboarding form.
	 */
	private static function defaultSiteUrl(): string {
		if ( function_exists( 'home_url' ) ) {
			return (string) home_url( '/' );
		}
		if ( function_exists( 'get_option' ) ) {
			$value = get_option( 'home', '' );
			return is_scalar( $value ) ? (string) $value : '';
		}
		return '';
	}

	/**
	 * Default site name for the affilun Cloud onboarding form.
	 */
	private static function defaultSiteName(): string {
		if ( function_exists( 'get_bloginfo' ) ) {
			return (string) get_bloginfo( 'name' );
		}
		if ( function_exists( 'get_option' ) ) {
			$value = get_option( 'blogname', '' );
			return is_scalar( $value ) ? (string) $value : '';
		}
		return '';
	}

	/**
	 * Default admin email for the affilun Cloud onboarding form.
	 */
	private static function defaultAdminEmail(): string {
		if ( function_exists( 'get_option' ) ) {
			$value = get_option( 'admin_email', '' );
			return is_scalar( $value ) ? (string) $value : '';
		}
		return '';
	}

	/**
	 * Fetch Cloud-side site status for the initial onboarding render.
	 *
	 * @param CloudSettings $cloud   Current Cloud settings.
	 * @param string        $siteUrl Site URL shown in the onboarding form.
	 *
	 * @return array<string,mixed>|null
	 */
	private static function remoteCloudStatus( CloudSettings $cloud, string $siteUrl ): ?array {
		if ( $siteUrl === '' || ! function_exists( 'wp_remote_post' ) || ! function_exists( 'wp_json_encode' ) ) {
			return null;
		}

		$body = wp_json_encode( array( 'site_url' => $siteUrl ) );
		if ( ! is_string( $body ) ) {
			return null;
		}

		try {
			$response = CloudHttpOptions::safePost(
				$cloud->endpoint() . '/billing/plugin/site-status',
				array(
					'timeout' => 5,
					'headers' => array(
						'Accept'       => 'application/json',
						'Content-Type' => 'application/json',
					),
					'body'    => $body,
				)
			);
			if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) {
				return null;
			}

			$status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' )
				? (int) wp_remote_retrieve_response_code( $response )
				: 0;
			if ( $status < 200 || $status >= 300 ) {
				return null;
			}

			$raw  = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' )
				? (string) wp_remote_retrieve_body( $response )
				: '';
			$data = json_decode( $raw, true );
			if ( ! is_array( $data ) ) {
				return null;
			}

			$payload = $data['data'] ?? $data;
			return is_array( $payload ) ? $payload : null;
		} catch ( Throwable $unused ) {
			unset( $unused );
			return null;
		}
	}

	/**
	 * Resolve the affilun Cloud account app URL from an API endpoint.
	 *
	 * @param string $endpoint affilun Cloud API endpoint.
	 */
	private static function cloudAccountUrlFromEndpoint( string $endpoint ): string {
		$base = preg_replace( '#/api/v1/?\z#', '', rtrim( $endpoint, '/' ) );
		if ( ! is_string( $base ) || $base === '' ) {
			$base = 'https://api.affilun.st';
		}

		return rtrim( $base, '/' ) . '/account/';
	}

	/**
	 * Render a single number-input row.
	 *
	 * @param string $label Visible label.
	 * @param string $name  HTML `name` attribute value.
	 * @param int    $value Current value.
	 */
	private function renderNumberRow( string $label, string $name, int $value ): void {
		echo '<tr>';
		echo '<th scope="row">' . esc_html( $label ) . '</th>';
		echo '<td>';
		echo '<input type="number" name="' . esc_attr( $name ) . '"'
			. ' form="' . esc_attr( self::SETTINGS_FORM_ID ) . '"'
			. ' value="' . esc_attr( (string) $value ) . '" class="small-text">';
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * Render the setup-guide link associated with an affiliate
	 * settings tab.
	 *
	 * @param string $key Provider or affiliate-routing tab key.
	 */
	private function renderSetupGuideLink( string $key ): void {
		$link = self::SETUP_GUIDE_LINKS[ $key ] ?? null;
		if ( $link === null ) {
			return;
		}

		echo '<p class="affilun-settings-guide-link">';
		echo '<a href="' . esc_url( $link['url'] ) . '" target="_blank" rel="noopener noreferrer">'
			. esc_html( $link['label'] )
			. '</a>';
		echo '</p>';
	}

	/**
	 * Render a single checkbox row.
	 *
	 * @param string $label   Visible label.
	 * @param string $name    HTML `name` attribute value.
	 * @param bool   $checked Whether the box should render checked.
	 */
	private function renderCheckboxRow( string $label, string $name, bool $checked ): void {
		echo '<tr>';
		// Checkbox rows skip the row-label <th> entirely and let
		// the cell span both columns so the box hugs the form-table's
		// left edge. The visible <label> next to the box carries the
		// only label text (no duplication with a left <th>).
		echo '<td colspan="2" style="padding-left:0;">';
		echo '<label>';
		echo '<input type="checkbox" name="' . esc_attr( $name ) . '" form="'
			. esc_attr( self::SETTINGS_FORM_ID ) . '" value="1"';
		if ( $checked ) {
			echo ' checked="checked"';
		}
		echo '>';
		echo ' ' . esc_html( $label );
		echo '</label>';
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * Render a single select row.
	 *
	 * @param string       $label   Visible label.
	 * @param string       $name    HTML `name` attribute value.
	 * @param string       $value   Current selected value.
	 * @param list<string> $options Closed-set option list.
	 */
	private function renderSelectRow( string $label, string $name, string $value, array $options ): void {
		echo '<tr>';
		echo '<th scope="row">' . esc_html( $label ) . '</th>';
		echo '<td>';
		echo '<select name="' . esc_attr( $name ) . '" form="' . esc_attr( self::SETTINGS_FORM_ID ) . '">';
		foreach ( $options as $option ) {
			$selected = $option === $value ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $option ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is one of two hard-coded literals (' selected="selected"' or ''); no user input is interpolated.
				. $selected
				. '>'
				. esc_html( $option ) . '</option>';
		}
		echo '</select>';
		echo '</td>';
		echo '</tr>';
	}



	/**
	 * Build a fully-namespaced HTML field name rooted at
	 * {@see SettingsFormToken::FORM_FIELD_PREFIX}.
	 *
	 * Variadic so callers can express arbitrary nesting:
	 * `fieldName('rendering', 'default_template')` →
	 * `affilun_settings[rendering][default_template]`.
	 *
	 * @param string ...$keys Path segments.
	 */
	private function fieldName( string ...$keys ): string {
		$name = SettingsFormToken::FORM_FIELD_PREFIX;
		foreach ( $keys as $key ) {
			$name .= '[' . $key . ']';
		}
		return $name;
	}

	/**
	 * Resolve a REST API URL.
	 *
	 * @param string $path REST route path without the `/wp-json/` prefix.
	 */
	private static function resolveRestUrl( string $path ): string {
		$fallback = '/wp-json/' . ltrim( $path, '/' );
		if ( function_exists( 'rest_url' ) ) {
			$url = (string) rest_url( $path );
			if ( function_exists( 'wp_parse_url' ) ) {
				$parts = wp_parse_url( $url );
				if ( is_array( $parts ) && is_scalar( $parts['path'] ?? null ) ) {
					$relative = (string) $parts['path'];
					if ( is_scalar( $parts['query'] ?? null ) ) {
						$relative .= '?' . (string) $parts['query'];
					}
					return $relative !== '' ? $relative : $fallback;
				}
			}
			return $url !== '' ? $url : $fallback;
		}

		return $fallback;
	}

	/**
	 * Resolve the `admin-post.php` URL the form submits to.
	 *
	 * Forwards to `admin_url()` when available; falls back to a
	 * synthetic absolute URL in pure-unit harnesses where
	 * WordPress is not loaded so `esc_url()` does not strip the
	 * value.
	 */
	private static function resolveAdminPostUrl(): string {
		if ( function_exists( 'admin_url' ) ) {
			return admin_url( 'admin-post.php' );
		}

		return 'http://example.test/wp-admin/admin-post.php';
	}

	/**
	 * Build the default nonce-field emitter.
	 *
	 * Forwards to `wp_nonce_field()` when available; otherwise
	 * a no-op so pure-unit harnesses without the WordPress
	 * function don't fatal.
	 *
	 * @phpstan-return callable(string, string): void
	 */
	private static function defaultNonceFieldEmitter(): callable {
		return static function ( string $action, string $name ): void {
			if ( function_exists( 'wp_nonce_field' ) ) {
				wp_nonce_field( $action, $name );
				return;
			}

			echo '<input type="hidden" name="'
				. esc_attr( $name )
				. '" value="'
				. esc_attr( $action )
				. '">';
		};
	}
}
