<?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 full single-page form (every §13 tab
 * stacked inline as `<h2>` sections; Phase-8 minimum carries
 * NO JS-driven tab switching) into the admin output stream and
 * is invoked from the page-render closure wired by
 * {@see \CommerceLinkBuilder\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 \CommerceLinkBuilder\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 \CommerceLinkBuilder\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 \CommerceLinkBuilder\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 CommerceLinkBuilder\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin\Page;

use CommerceLinkBuilder\Application\Settings\CacheSettings;
use CommerceLinkBuilder\Application\Settings\LinkDefaults;
use CommerceLinkBuilder\Application\Settings\PluginSettings;
use CommerceLinkBuilder\Application\Settings\ProviderCredentials;
use CommerceLinkBuilder\Application\Settings\RenderingDefaults;
use CommerceLinkBuilder\Application\Settings\SettingsRepository;
use CommerceLinkBuilder\Application\Settings\SyncSettings;

/**
 * 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'     => 'Credential ID (クレデンシャル ID)',
				'credential_secret' => 'Credential Secret (シークレット)',
				'version'           => 'Credential Version (2.1 / 2.2 / 2.3 / 3.1 / 3.2 / 3.3)',
				'partner_tag'       => 'Partner Tag (Associate Tag)',
				'marketplace'       => 'Marketplace (例: www.amazon.co.jp)',
			),
		),
		'rakuten'        => array(
			'label'   => '楽天市場',
			'secrets' => array(
				'application_id' => 'Application ID (アプリ ID)',
				'affiliate_id'   => 'Affiliate ID (アフィリエイト ID)',
			),
		),
		'yahoo_shopping' => array(
			'label'   => 'Yahoo!ショッピング',
			'secrets' => array(
				'app_id' => 'Client ID',
			),
		),
	);

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

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

	/**
	 * Construct the renderer.
	 *
	 * @param SettingsRepository $repository        Settings repository.
	 * @param callable|null      $nonceFieldEmitter Optional nonce-field emitter override.
	 *
	 * @phpstan-param (callable(string, string): void)|null $nonceFieldEmitter
	 */
	public function __construct(
		SettingsRepository $repository,
		?callable $nonceFieldEmitter = null
	) {
		$this->repository        = $repository;
		$this->nonceFieldEmitter = $nonceFieldEmitter ?? self::defaultNonceFieldEmitter();
	}

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

		echo '<div class="wrap">';
		echo '<h1>' . esc_html( 'Commerce Link Builder 設定' ) . '</h1>';

		$this->renderStatusFlash( $statusFlash );

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

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

		$this->renderRenderingSection( $settings->rendering() );
		$this->renderLinksSection( $settings->links() );
		$this->renderSyncSection( $settings->sync() );
		$this->renderCacheSection( $settings->cache() );
		$this->renderProvidersSection( $settings );
		$this->renderAdvancedSection( $settings );

		echo '<p class="submit">';
		echo '<button type="submit" class="button button-primary">';
		echo esc_html( '設定を保存' );
		echo '</button>';
		echo '</p>';

		echo '</form>';
		echo '</div>';
	}

	/**
	 * 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;
			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 §13.5 Rendering section.
	 *
	 * @param RenderingDefaults $rendering Current rendering defaults.
	 */
	private function renderRenderingSection( RenderingDefaults $rendering ): void {
		echo '<h2>' . esc_html( '表示設定' ) . '</h2>';
		echo '<table class="form-table">';

		$this->renderTextRow(
			'デフォルトテンプレート',
			$this->fieldName( 'rendering', 'default_template' ),
			$rendering->defaultTemplate()
		);

		$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>';
	}

	/**
	 * 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 the §13.4 Providers section, applying the §6.3
	 * mask + rotating-input flow per provider.
	 *
	 * @param PluginSettings $settings Current settings snapshot.
	 */
	private function renderProvidersSection( PluginSettings $settings ): void {
		echo '<h2>' . esc_html( 'プロバイダー' ) . '</h2>';

		foreach ( self::PROVIDER_SCHEMA as $providerKey => $schema ) {
			$current = $settings->provider( $providerKey );
			$this->renderProviderBlock( $providerKey, $schema, $current );
		}
	}

	/**
	 * 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 {
		echo '<h3>' . esc_html( $schema['label'] ) . '</h3>';

		// KI-019 Phase B (ADR-0022): Amazon now uses Creators API
		// credentials (OAuth 2.0 + credential ID / secret / version /
		// partner tag / marketplace), so the settings UI surfaces
		// qualification and migration guidance.
		if ( $providerKey === 'amazon' ) {
			echo '<div class="notice notice-info inline" style="margin: 8px 0; padding: 8px 12px;">';
			echo '<p style="margin: 0;"><strong>'
				. esc_html( 'Amazon Creators API について' )
				. '</strong></p>';
			echo '<p style="margin: 4px 0 0 0;">'
				. esc_html(
					'本プラグインの Amazon 連携は Creators API のみを採用しています '
					. '(PA-API 5.0 は 2026-05-15 に Amazon が deprecated にするため未採用、ADR-0022)。'
					. 'Credentials は Amazon Associates Central の Tools > Creators API から発行し、'
					. 'Credential ID / Credential Secret / Credential Version の 3 値を以下に入力してください。'
					. 'Partner Tag は Associates アカウントの Associate Tag を、Marketplace は対象ロケール '
					. '(例: 日本なら www.amazon.co.jp、US なら www.amazon.com) を入力してください。'
				)
				. '</p>';
			echo '<p style="margin: 4px 0 0 0;">'
				. esc_html(
					'※ Creators API は資格要件として「過去 30 日間で 10 件以上の qualifying sales」が必要です。'
					. '新規 Associate アカウントでは Creators API credentials の発行ができない場合があります。'
				)
				. '</p>';
			echo '<p style="margin: 4px 0 0 0;">'
				. esc_html(
					'※ 旧 PA-API 5.0 credentials (Access Key / Secret Key) を保存していた場合、自動変換は'
					. '不可能なため、Associates Central で Creators API credentials を新規発行してください。'
				)
				. '</p>';
			echo '</div>';
		}

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

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

		foreach ( $schema['secrets'] as $secretKey => $secretLabel ) {
			$plain = $current !== null ? (string) $current->secret( $secretKey ) : '';
			$name  = $this->fieldName( 'providers', $providerKey, $secretKey );

			echo '<tr>';
			echo '<th scope="row">' . esc_html( $secretLabel ) . '</th>';
			echo '<td>';
			if ( $plain !== '' ) {
				echo '<span class="clb-current-secret">'
					. esc_html( '現在の値: ' )
					. esc_html( $plain )
					. '</span><br>';
			} else {
				echo '<span class="clb-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 ) . '"'
				. ' value="" autocomplete="off" spellcheck="false"'
				. ' class="regular-text" placeholder="変更しない場合は空欄のまま"'
				. '>';
			echo '</td>';
			echo '</tr>';
		}

		echo '</table>';
	}

	/**
	 * Render the §13.9 Advanced section (Phase-8 minimum: debug
	 * mode toggle only).
	 *
	 * @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 single text-input row.
	 *
	 * @param string $label Visible label.
	 * @param string $name  HTML `name` attribute value.
	 * @param string $value Current value.
	 */
	private function renderTextRow( string $label, string $name, string $value ): void {
		echo '<tr>';
		echo '<th scope="row">' . esc_html( $label ) . '</th>';
		echo '<td>';
		echo '<input type="text" name="' . esc_attr( $name ) . '"'
			. ' value="' . esc_attr( $value ) . '" class="regular-text">';
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * 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 ) . '"'
			. ' value="' . esc_attr( (string) $value ) . '" class="small-text">';
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * 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 ) . '" 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 ) . '">';
		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')` →
	 * `clb_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 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 )
				. '">';
		};
	}
}
