<?php
/**
 * Products list-page renderer.
 *
 * Phase-8 slice 8d server-side renderer for the §6 Products list
 * admin page documented in `specifications/admin-ui-spec.md`. The
 * renderer echoes the page HTML — filter form, paginated results
 * table, empty-state message, and pagination nav — 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 the §3
 * editor-tier capability via {@see AdminCapability::CAP_VIEW_PRODUCTS}
 * and `wp_die`'s on denial. The renderer just emits HTML.
 *
 * Phase-8 minimum scope (intentional omissions):
 *
 *   - Row actions support edit, logical delete, and affilun Cloud
 *     article-draft generation. Broader metadata editing and Sync Now
 *     remain separate admin slices.
 *   - Product usage is resolved by scanning post content for AFFILUN
 *     Gutenberg block attributes on the current page of products.
 *
 * Result composition:
 *
 *   - The renderer calls {@see ListProductsUseCase::execute()} with
 *     the supplied query and walks the resulting
 *     {@see ProductListResult}'s items, routing each
 *     {@see ProductRecord} through {@see ProductSummaryMapper::map()}
 *     to derive the row's display shape. Every visible field flows
 *     through `esc_html()` / `esc_attr()` so a malicious title /
 *     brand cannot escape the surrounding markup.
 *
 *   - When the result is empty the renderer emits the §6.7
 *     `No products found.` message plus an inline note that the
 *     "Add New Product" CTA lands in a future slice.
 *
 * Filter form:
 *
 *   - GET form submitting back to `admin.php?page=affilun-products`
 *     so the parsed query state survives a page reload.
 *   - Provider / sync-state / sort / order are rendered as
 *     `<select>` elements driven by the {@see ProductListQuery}
 *     closed-set whitelists.
 *   - Search is a free-form text input.
 *
 * Pagination:
 *
 *   - Phase-8 minimum: textual `Page X of Y` plus prev / next links
 *     that preserve the current filter state via
 *     {@see self::buildPageUrl()}.
 *   - When the result spans a single page the renderer emits the
 *     `Page X of Y` text without prev / next anchors.
 *
 * Escaping invariants:
 *
 *   - Every visible text emission flows through `esc_html()`.
 *   - Every attribute value flows through `esc_attr()`.
 *   - Every URL flows through `esc_url()`.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Application\Product\Csv\ProductCsvImportResult;
use Affilun\Application\Product\Csv\ProductCsvImportRow;
use Affilun\Application\Cloud\CloudProductArticleDraftClient;
use Affilun\Application\Cloud\CloudUsageClient;
use Affilun\Application\LinkHealth\LinkHealthStatusClassifier;
use Affilun\Application\LinkHealth\ProductLinkHealthStatusStore;
use Affilun\Application\Product\Csv\ProductCsvImportUseCase;
use Affilun\Application\Product\ProductManualTaxonomy;
use Affilun\Application\Product\UseCase\BulkUpdateProductsResult;
use Affilun\Application\Product\UseCase\BulkUpdateProductsUseCase;
use Affilun\Application\Product\UseCase\ListProductsUseCase;
use Affilun\Application\Settings\CloudSettings;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Infrastructure\Persistence\ProductListQuery;
use Affilun\Infrastructure\Persistence\ProductMetaRepository;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Presentation\Admin\AdminPriceFormatter;
use Affilun\Presentation\Admin\AdminTimeFormatter;
use Affilun\Presentation\Rest\Mapper\ProductSummaryMapper;

/**
 * Renders the §6 Products list admin page to stdout.
 *
 * See the file-level docblock for the Phase-8 minimum scope, the
 * filter / pagination behaviour, and the escaping invariants.
 */
final class ProductsListPageRenderer {

	/**
	 * Admin menu slug the filter form submits back to.
	 */
	private const MENU_SLUG = 'affilun-products';

	/**
	 * Safety ceiling for scanning saved products when rendering the focused panel.
	 */
	private const LINK_HEALTH_PROBLEM_SCAN_LIMIT = 500;

	/**
	 * Number of link-health problem products shown per focused-panel page.
	 */
	private const LINK_HEALTH_PROBLEM_PER_PAGE = 20;

	/**
	 * Query parameter used by the link-health problem panel pagination.
	 */
	private const LINK_HEALTH_PROBLEM_PAGE_PARAM = 'affilun_link_health_problem_paged';

	/**
	 * Query parameter used by the link-health problem panel page-size selector.
	 */
	private const LINK_HEALTH_PROBLEM_PER_PAGE_PARAM = 'affilun_link_health_problem_per_page';

	/**
	 * Page-size choices exposed on admin tables.
	 *
	 * @var list<int>
	 */
	private const PER_PAGE_OPTIONS = array( 10, 20, 50 );

	/**
	 * Closed list of providers offered in the filter dropdown.
	 *
	 * Keyed by the wire token; values are the human-readable label.
	 *
	 * @var array<string, string>
	 */
	private const PROVIDERS = array(
		'amazon'         => 'Amazon',
		'rakuten'        => 'Rakuten',
		'yahoo_shopping' => 'Yahoo Shopping',
		'mercari'        => 'Mercari',
	);

	/**
	 * Article-draft tone labels accepted by the Cloud endpoint.
	 *
	 * @var array<string,string>
	 */
	private const ARTICLE_DRAFT_TONES = array(
		'professional_blog' => 'プロらしいブログ調',
		'friendly'          => '親しみやすい紹介調',
		'review_comparison' => '比較レビュー調',
		'concise'           => '簡潔な要点整理',
	);

	/**
	 * Article-draft target length labels accepted by the Cloud endpoint.
	 *
	 * @var array<string,string>
	 */
	private const ARTICLE_DRAFT_LENGTHS = array(
		'short'  => '短め',
		'medium' => '標準',
		'long'   => '長め',
	);

	/**
	 * Bulk preview/apply action.
	 */
	private const BULK_ACTION = 'affilun_bulk_update_products';

	/**
	 * Bulk nonce field.
	 */
	private const BULK_NONCE_FIELD = '_affilun_bulk_update_products_nonce';

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

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

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

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

	/**
	 * CSV preview nonce action.
	 */
	private const CSV_PREVIEW_NONCE_ACTION = 'affilun_preview_products_csv';

	/**
	 * CSV preview nonce field.
	 */
	private const CSV_PREVIEW_NONCE_FIELD = '_affilun_preview_products_csv_nonce';

	/**
	 * §8.2 list-products use case.
	 */
	private ListProductsUseCase $listUseCase;

	/**
	 * §6.1 row-shape mapper.
	 */
	private ProductSummaryMapper $summaryMapper;

	/**
	 * Resolves post usage for products on the current page.
	 */
	private PostProductUsageResolver $usageResolver;

	/**
	 * Bulk update use case.
	 */
	private ?BulkUpdateProductsUseCase $bulkUseCase;

	/**
	 * CSV import use case.
	 */
	private ?ProductCsvImportUseCase $csvImportUseCase;

	/**
	 * Embedded add-new product renderer.
	 */
	private AddNewProductPageRenderer $addNewRenderer;

	/**
	 * Optional settings repository for affilun Cloud feature availability.
	 */
	private ?SettingsRepository $settingsRepository;

	/**
	 * Optional product repository for selected-row article draft generation.
	 */
	private ?ProductRepository $productRepository;

	/**
	 * Optional product meta repository for link-health status display.
	 */
	private ?ProductMetaRepository $productMetaRepository;

	/**
	 * affilun Cloud article-draft client.
	 */
	private CloudProductArticleDraftClient $articleDraftClient;

	/**
	 * affilun Cloud usage client.
	 */
	private CloudUsageClient $usageClient;

	/**
	 * Optional Amazon discount shortcode configurator.
	 */
	private ?AmazonDiscountShortcodeCardRenderer $amazonDiscountCardRenderer;

	/**
	 * Construct the renderer with explicit collaborators.
	 *
	 * @param ListProductsUseCase                      $listUseCase   §8.2 list use case.
	 * @param ProductSummaryMapper                     $summaryMapper §6.1 row mapper.
	 * @param PostProductUsageResolver|null            $usageResolver Optional product usage resolver.
	 * @param BulkUpdateProductsUseCase|null           $bulkUseCase     Optional bulk update use case.
	 * @param ProductCsvImportUseCase|null             $csvImportUseCase Optional CSV import use case.
	 * @param AddNewProductPageRenderer|null           $addNewRenderer   Optional add-new renderer.
	 * @param SettingsRepository|null                  $settingsRepository Optional settings repository.
	 * @param ProductRepository|null                   $productRepository Optional product repository.
	 * @param CloudProductArticleDraftClient|null      $articleDraftClient Optional Cloud article client.
	 * @param CloudUsageClient|null                    $usageClient Optional Cloud usage client.
	 * @param ProductMetaRepository|null               $productMetaRepository Optional product meta repository.
	 * @param AmazonDiscountShortcodeCardRenderer|null $amazonDiscountCardRenderer Optional discount shortcode card.
	 */
	public function __construct(
		ListProductsUseCase $listUseCase,
		ProductSummaryMapper $summaryMapper,
		?PostProductUsageResolver $usageResolver = null,
		?BulkUpdateProductsUseCase $bulkUseCase = null,
		?ProductCsvImportUseCase $csvImportUseCase = null,
		?AddNewProductPageRenderer $addNewRenderer = null,
		?SettingsRepository $settingsRepository = null,
		?ProductRepository $productRepository = null,
		?CloudProductArticleDraftClient $articleDraftClient = null,
		?CloudUsageClient $usageClient = null,
		?ProductMetaRepository $productMetaRepository = null,
		?AmazonDiscountShortcodeCardRenderer $amazonDiscountCardRenderer = null
	) {
		$this->listUseCase                = $listUseCase;
		$this->summaryMapper              = $summaryMapper;
		$this->usageResolver              = $usageResolver ?? new PostProductUsageResolver();
		$this->bulkUseCase                = $bulkUseCase;
		$this->csvImportUseCase           = $csvImportUseCase;
		$this->addNewRenderer             = $addNewRenderer ?? new AddNewProductPageRenderer();
		$this->settingsRepository         = $settingsRepository;
		$this->productRepository          = $productRepository;
		$this->articleDraftClient         = $articleDraftClient ?? new CloudProductArticleDraftClient();
		$this->usageClient                = $usageClient ?? new CloudUsageClient();
		$this->productMetaRepository      = $productMetaRepository;
		$this->amazonDiscountCardRenderer = $amazonDiscountCardRenderer;
	}

	/**
	 * Echo the §6 Products list page HTML to stdout.
	 *
	 * @param ProductListQuery    $query       Already-validated list query.
	 * @param array<string,mixed> $requestData GET-like request data.
	 */
	public function render( ProductListQuery $query, array $requestData = array() ): void {
		$result = $this->listUseCase->execute( $query );

		$logoUrl = defined( 'AFFILUN_PLUGIN_URL' )
			? AFFILUN_PLUGIN_URL . 'assets/img/brand/logo-landscape-transparent.png'
			: '';

		echo '<div class="wrap affilun-admin-page affilun-products-page">';
		echo '<div class="affilun-admin-hero">';
		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 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 '</div>';

		$cloudContracted  = $this->isCloudContracted();
		$cloudSettingsUrl = self::cloudSettingsUrl();
		if ( $cloudContracted ) {
			$this->renderLinkHealthProblemPanel( $query, $requestData );
		}

		$this->renderStatusNotice( $requestData );
		$this->renderAddNewProductPanel();
		$this->renderArticleDraftPanel( $requestData, $cloudContracted, $cloudSettingsUrl );

		echo '<div class="affilun-products-list-card">';
		echo '<h2>' . esc_html( '商品の一覧' ) . '</h2>';
		echo '<div class="affilun-products-list-row affilun-products-list-row--views-search">';
		$this->renderStatusViews( $query );
		$this->renderSearchForm( $query );
		echo '</div>';

		if ( $result->isEmpty() ) {
			echo '<div class="affilun-products-list-row affilun-products-list-row--actions-filter">';
			$this->renderFilterForm( $query );
			$this->renderItemCount( $result->totalItems() );
			echo '</div>';
			$this->renderEmptyState();
			echo '</div>';
			$this->renderProductCsvPanel( $requestData );
			$this->renderAmazonDiscountShortcodeCard( $requestData );
			echo '</div>';
			return;
		}

		$records        = $result->items();
		$productIds     = self::recordIds( $records );
		$usageByProduct = $this->usageResolver->resolveForProductIds( $productIds );

		$this->renderDetachedBulkForm();
		echo '<div class="affilun-products-list-row affilun-products-list-row--actions-filter">';
		$this->renderBulkActionControls( $result->totalItems(), 'top' );
		$this->renderFilterForm( $query );
		$this->renderItemCount( $result->totalItems() );
		echo '</div>';

		echo '<table class="wp-list-table widefat fixed striped affilun-products-table">';
		$this->renderTableHead();
		echo '<tbody>';
		foreach ( $records as $record ) {
			$id    = $record->id();
			$usage = $id === null ? array() : ( $usageByProduct[ $id ] ?? array() );
			$this->renderRow( $record, $usage, $cloudContracted, $cloudSettingsUrl );
		}
		echo '</tbody>';
		echo '</table>';

		echo '<div class="affilun-products-list-row affilun-products-list-row--bulk-bottom">';
		$this->renderBulkActionControls( $result->totalItems(), 'bottom' );
		$this->renderItemCount( $result->totalItems() );
		echo '</div>';

		echo '<div class="affilun-products-list-row affilun-products-list-row--pagination">';
		$this->renderPagination( $query, $result->totalPages(), $result->totalItems() );
		echo '</div>';
		echo '</div>';

		if ( self::requestString( $requestData, 'affilun_bulk_action' ) === 'edit' ) {
			$this->renderBulkOperationsPanel( $requestData );
		}
		$this->renderProductCsvPanel( $requestData );
		$this->renderAmazonDiscountShortcodeCard( $requestData );
		echo '</div>';
	}

	/**
	 * @param array<string,mixed> $requestData GET-like request data.
	 */
	private function renderAmazonDiscountShortcodeCard( array $requestData ): void {
		if ( $this->amazonDiscountCardRenderer !== null ) {
			$this->amazonDiscountCardRenderer->render( $requestData );
		}
	}

	/**
	 * Render embedded add-new product controls at the top of Product Management.
	 */
	private function renderAddNewProductPanel(): void {
		echo '<div class="affilun-products-list-card affilun-add-new-product-panel">';
		echo '<h2>' . esc_html( '商品を追加' ) . '</h2>';
		$this->addNewRenderer->renderEmbedded();
		echo '</div>';
	}

	/**
	 * Extract persisted ids from product records.
	 *
	 * @param list<ProductRecord> $records Product records.
	 *
	 * @return list<int>
	 */
	private static function recordIds( array $records ): array {
		$ids = array();
		foreach ( $records as $record ) {
			$id = $record->id();
			if ( $id !== null ) {
				$ids[] = $id;
			}
		}

		return $ids;
	}

	/**
	 * Render a small status notice for admin-post round-trips.
	 *
	 * @param array<string,mixed> $requestData GET-like request data.
	 */
	private function renderStatusNotice( array $requestData ): void {
		$status   = self::requestString( $requestData, 'affilun_status' );
		$messages = array(
			'bulk_updated'          => '一括更新を適用しました。',
			'bulk_none'             => '条件に一致する商品はありませんでした。',
			'bulk_error'            => '一括更新に失敗しました。入力内容を確認してください。',
			'bulk_trash_none'       => 'ゴミ箱へ移動する商品を選択してください。',
			'csv_imported'          => 'CSV インポートを適用しました。',
			'csv_import_none'       => 'CSV に適用できる商品行はありませんでした。',
			'csv_import_error'      => 'CSV インポートに失敗しました。プレビュー内容を確認してください。',
			'deleted'               => '商品をゴミ箱へ移動しました。',
			'article_draft_created' => 'WordPress投稿下書きを作成しました。',
			'article_draft_error'   => 'WordPress投稿下書きの作成に失敗しました。',
			'purged'                => '商品を完全に削除しました。',
			'delete_error'          => '商品の削除に失敗しました。',
			'invalid_bulk'          => '一括操作を選択してください。',
			'invalid_nonce'         => '操作を確認できませんでした。もう一度お試しください。',
			'forbidden'             => 'この操作を実行する権限がありません。',
			'saved'                 => '商品を保存しました。',
		);
		if ( $status === null || ! array_key_exists( $status, $messages ) ) {
			return;
		}

		echo '<div class="notice notice-info"><p>' . esc_html( $messages[ $status ] ) . '</p></div>';
	}

	/**
	 * Render user-side state view links like the WordPress posts list.
	 *
	 * @param ProductListQuery $query Current query.
	 */
	private function renderStatusViews( ProductListQuery $query ): void {
		$views = array(
			''        => 'すべて',
			'publish' => 'publish',
			'unused'  => 'unused',
			'trash'   => 'trash',
		);

		echo '<ul class="subsubsub affilun-product-status-views">';
		$first = true;
		foreach ( $views as $state => $label ) {
			$lifecycle  = self::lifecycleFromUserState( $state );
			$countQuery = $query->withLifecycleState( $lifecycle )->withPage( 1 )->withPerPage( 1 );
			$count      = $this->listUseCase->execute( $countQuery )->totalItems();
			$url        = self::buildFilteredUrl( $query->withLifecycleState( $lifecycle )->withPage( 1 ) );
			$current    = $query->lifecycleState() === $lifecycle;
			if ( ! $first ) {
				echo ' | ';
			}
			echo '<li><a href="' . esc_url( $url ) . '"' . ( $current ? ' class="current"' : '' ) . '>'
				. esc_html( sprintf( '%s (%d)', $label, $count ) ) . '</a></li>';
			$first = false;
		}
		echo '</ul>';
	}

	/**
	 * Render the hidden POST form used by table checkboxes/buttons.
	 */
	private function renderDetachedBulkForm(): void {
		echo '<form id="affilun-products-bulk-form" method="post" action="'
			. esc_url( self::resolveAdminUrl( 'admin-post.php' ) )
			. '">';
		echo '<input type="hidden" name="action" value="affilun_bulk_products_action">';
		self::renderNonceField( 'affilun_bulk_products_action', '_affilun_bulk_products_action_nonce' );
		echo '</form>';
	}

	/**
	 * Render item count.
	 *
	 * @param int $totalItems Total matching items.
	 */
	private function renderItemCount( int $totalItems ): void {
		echo '<span class="displaying-num affilun-products-item-count">'
			. esc_html( sprintf( '%d 個の項目', $totalItems ) )
			. '</span>';
	}

	/**
	 * Render bulk action controls above/below the table.
	 *
	 * @param int    $totalItems Total matching items.
	 * @param string $position   Visual position token.
	 */
	private function renderBulkActionControls( int $totalItems, string $position ): void {
		unset( $totalItems );
		echo '<div class="alignleft actions bulkactions affilun-bulk-action-controls affilun-bulk-action-controls--'
			. esc_attr( $position ) . '">';
		echo '<label for="affilun-bulk-action-' . esc_attr( $position ) . '" class="screen-reader-text">'
			. esc_html( '一括操作' ) . '</label>';
		$name = $position === 'bottom' ? 'affilun_bulk_action_select2' : 'affilun_bulk_action_select';
		echo '<select id="affilun-bulk-action-' . esc_attr( $position ) . '" name="' . esc_attr( $name ) . '" form="affilun-products-bulk-form">';
		echo '<option value="">' . esc_html( '一括操作' ) . '</option>';
		echo '<option value="edit">' . esc_html( '一括編集' ) . '</option>';
		echo '<option value="trash">' . esc_html( 'ゴミ箱へ移動' ) . '</option>';
		echo '</select> ';
		echo '<button type="submit" form="affilun-products-bulk-form" class="button action">'
			. esc_html( '適用' ) . '</button>';
		echo '</div>';
	}

	/**
	 * Render bulk preview/apply controls.
	 *
	 * @param array<string,mixed> $requestData GET-like request data.
	 */
	private function renderBulkOperationsPanel( array $requestData ): void {
		if ( $this->bulkUseCase === null ) {
			return;
		}

		$filters = self::bulkFiltersFromRequest( $requestData );
		$changes = self::bulkChangesFromRequest( $requestData );

		echo '<div class="affilun-bulk-products">';
		echo '<h2>' . esc_html( '一括編集' ) . '</h2>';
		echo '<form method="get" action="">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';
		echo '<input type="hidden" name="affilun_bulk_preview" value="1">';
		$this->renderBulkFilterControls( $filters );
		$this->renderBulkChangeControls( $changes );
		echo '<p><button type="submit" class="button button-secondary">' . esc_html( 'プレビュー' ) . '</button></p>';
		echo '</form>';

		if ( self::requestString( $requestData, 'affilun_bulk_preview' ) === '1' ) {
			$this->renderBulkPreview( $filters, $changes );
		}

		echo '</div>';
	}

	/**
	 * Render CSV import/export controls.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 */
	private function renderProductCsvPanel( array $requestData ): void {
		echo '<div class="affilun-product-csv">';
		echo '<h2>' . esc_html( 'CSV インポート / エクスポート' ) . '</h2>';
		echo '<p class="description">';
		echo esc_html( '保存済み商品を CSV としてバックアップし、affilun からエクスポートした CSV 形式のみをプレビュー後に取り込めます。' );
		echo '</p>';

		$this->renderProductCsvExportForm();
		$this->renderProductCsvPreviewForm( $requestData );

		if ( self::rawRequestString( $requestData, 'affilun_csv_preview' ) === '1' ) {
			$this->renderProductCsvPreview( $requestData );
		}

		echo '</div>';
	}

	/**
	 * Render CSV export form.
	 */
	private function renderProductCsvExportForm(): void {
		echo '<form method="post" action="' . esc_url( self::resolveAdminUrl( 'admin-post.php' ) ) . '"';
		echo ' style="margin:8px 0 16px;">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::CSV_EXPORT_ACTION ) . '">';
		self::renderNonceField( self::CSV_EXPORT_ACTION, self::CSV_EXPORT_NONCE_FIELD );
		echo '<button type="submit" class="button button-secondary">' . esc_html( 'CSV をエクスポート' ) . '</button>';
		echo '</form>';
	}

	/**
	 * Render CSV preview form.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 */
	private function renderProductCsvPreviewForm( array $requestData ): void {
		$csv = self::rawRequestString( $requestData, 'affilun_product_csv' );
		echo '<form method="post" action="">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';
		echo '<input type="hidden" name="affilun_csv_preview" value="1">';
		self::renderNonceField( self::CSV_PREVIEW_NONCE_ACTION, self::CSV_PREVIEW_NONCE_FIELD );
		echo '<p><label for="affilun-product-csv">' . esc_html( 'CSV 内容' ) . '</label><br>';
		echo '<textarea id="affilun-product-csv" name="affilun_product_csv" class="large-text code" rows="8"';
		echo ' placeholder="' . esc_attr( 'エクスポートした CSV を貼り付け' ) . '">';
		echo esc_textarea( $csv );
		echo '</textarea></p>';
		echo '<p><button type="submit" class="button button-secondary">' . esc_html( 'インポートをプレビュー' ) . '</button></p>';
		echo '</form>';
	}

	/**
	 * Render CSV import preview.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 */
	private function renderProductCsvPreview( array $requestData ): void {
		if ( $this->csvImportUseCase === null ) {
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( 'CSV インポート機能を初期化できませんでした。' );
			echo '</p></div>';
			return;
		}

		$nonce = trim( self::rawRequestString( $requestData, self::CSV_PREVIEW_NONCE_FIELD ) );
		if ( ! self::verifyNonce( $nonce, self::CSV_PREVIEW_NONCE_ACTION ) ) {
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( 'CSV プレビュー操作を確認できませんでした。' );
			echo '</p></div>';
			return;
		}

		$csv = self::rawRequestString( $requestData, 'affilun_product_csv' );
		try {
			$result = $this->csvImportUseCase->preview( $csv );
		} catch ( \Throwable $unused ) {
			unset( $unused );
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( 'CSV を解析できませんでした。内容を確認してください。' );
			echo '</p></div>';
			return;
		}

		$this->renderProductCsvPreviewResult( $result, $csv );
	}

	/**
	 * Render parsed CSV preview result.
	 *
	 * @param ProductCsvImportResult $result Preview result.
	 * @param string                 $csv    Original CSV payload.
	 */
	private function renderProductCsvPreviewResult( ProductCsvImportResult $result, string $csv ): void {
		$noticeClass = $result->hasErrors() ? 'notice-error' : 'notice-warning';
		echo '<div class="notice ' . esc_attr( $noticeClass ) . ' inline"><p>';
		echo esc_html(
			sprintf(
				'%d 件中 %d 件の CSV 行を取り込み可能です。適用前に内容を確認してください。',
				$result->totalRows(),
				$result->validRows()
			)
		);
		echo '</p></div>';

		foreach ( $result->errors() as $error ) {
			echo '<p class="description" style="color:#b32d2e;">' . esc_html( $error ) . '</p>';
		}

		$this->renderProductCsvPreviewTable( $result );
		if ( $result->hasErrors() || $result->validRows() < 1 ) {
			return;
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encodes previewed CSV into a hidden form field, not executable code.
		$encodedCsv = base64_encode( $csv );

		echo '<form method="post" action="' . esc_url( self::resolveAdminUrl( 'admin-post.php' ) ) . '">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::CSV_IMPORT_ACTION ) . '">';
		echo '<input type="hidden" name="affilun_product_csv_payload" value="'
			. esc_attr( $encodedCsv ) . '">';
		self::renderNonceField( self::csvImportNonceAction( $csv ), self::CSV_IMPORT_NONCE_FIELD );
		echo '<button type="submit" class="button button-primary" onclick="return confirm(\''
			. esc_attr( 'プレビューした CSV を商品データに適用しますか？' )
			. '\');">' . esc_html( 'この CSV をインポート' ) . '</button>';
		echo '</form>';
	}

	/**
	 * Render CSV preview table.
	 *
	 * @param ProductCsvImportResult $result Preview result.
	 */
	private function renderProductCsvPreviewTable( ProductCsvImportResult $result ): void {
		if ( $result->rows() === array() ) {
			return;
		}

		echo '<table class="widefat striped" style="margin:8px 0 12px;">';
		echo '<thead><tr>';
		foreach ( array( '行', '操作', 'プロバイダー', '商品ID', '商品名', '状態', 'カテゴリ', 'タグ', '検証' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead><tbody>';
		foreach ( $result->rows() as $row ) {
			$this->renderProductCsvPreviewRow( $row );
		}
		echo '</tbody></table>';
	}

	/**
	 * Render one CSV preview row.
	 *
	 * @param ProductCsvImportRow $row Preview row.
	 */
	private function renderProductCsvPreviewRow( ProductCsvImportRow $row ): void {
		$actionLabel = $row->action() === ProductCsvImportRow::ACTION_UPDATE ? '更新' : '作成';
		$errors      = $row->errors();
		echo '<tr>';
		echo '<td>' . esc_html( (string) $row->rowNumber() ) . '</td>';
		echo '<td>' . esc_html( $actionLabel ) . '</td>';
		echo '<td>' . esc_html( $row->provider() ) . '</td>';
		echo '<td>' . esc_html( $row->providerItemId() ) . '</td>';
		echo '<td>' . esc_html( $row->title() ) . '</td>';
		echo '<td>' . esc_html( $row->lifecycleState() ) . '</td>';
		echo '<td>' . esc_html( ProductManualTaxonomy::termsToDisplayString( $row->categories() ) ) . '</td>';
		echo '<td>' . esc_html( ProductManualTaxonomy::termsToDisplayString( $row->tags() ) ) . '</td>';
		echo '<td>' . esc_html( $errors === array() ? '正常' : implode( ' / ', $errors ) ) . '</td>';
		echo '</tr>';
	}

	/**
	 * Render affilun Cloud article-draft preview panel when requested.
	 *
	 * @param array<string,mixed> $requestData      Request data.
	 * @param bool                $cloudContracted  Whether affilun Cloud is contracted.
	 * @param string              $cloudSettingsUrl affilun Cloud settings URL.
	 */
	private function renderArticleDraftPanel( array $requestData, bool $cloudContracted, string $cloudSettingsUrl ): void {
		$productId = self::requestPositiveInt( $requestData, 'affilun_article_draft_product_id' );
		if ( $productId === null ) {
			return;
		}

		echo '<div class="affilun-products-list-card affilun-article-draft-panel" style="margin:16px 0;">';
		echo '<h2>' . esc_html( '記事下書き作成' ) . '</h2>';

		if ( ! $cloudContracted ) {
			echo '<div class="notice notice-warning inline"><p>'
				. esc_html( '記事下書き機能の利用にはaffilun cloudの契約が必要です。' )
				. ' <a href="' . esc_url( $cloudSettingsUrl ) . '">'
				. esc_html( 'affilun Cloud設定へ' )
				. '</a></p></div>';
			echo '</div>';
			return;
		}

		$nonce = self::rawRequestString( $requestData, '_wpnonce' );
		if ( ! self::verifyNonce( $nonce, self::articleDraftNonceAction( $productId ) ) ) {
			echo '<div class="notice notice-error inline"><p>'
				. esc_html( '記事下書き作成リンクが無効です。保存商品一覧からもう一度お試しください。' )
				. '</p></div>';
			echo '</div>';
			return;
		}

		if ( $this->productRepository === null || $this->settingsRepository === null ) {
			echo '<div class="notice notice-error inline"><p>'
				. esc_html( '記事下書き作成機能を初期化できませんでした。' )
				. '</p></div>';
			echo '</div>';
			return;
		}

		try {
			$product = $this->productRepository->findById( $productId );
			$cloud   = $this->settingsRepository->load()->cloud();
		} catch ( \Throwable $unused ) {
			unset( $unused );
			echo '<div class="notice notice-error inline"><p>'
				. esc_html( '対象の商品情報を読み込めませんでした。' )
				. '</p></div>';
			echo '</div>';
			return;
		}

		if ( $product === null ) {
			echo '<div class="notice notice-error inline"><p>'
				. esc_html( '対象の商品が見つかりませんでした。' )
				. '</p></div>';
			echo '</div>';
			return;
		}

		echo '<p class="description">' . esc_html( sprintf( '対象商品: #%d %s / %s', $productId, $product->provider(), $product->title() ) ) . '</p>';

		$options    = self::articleDraftOptionsFromRequest( $requestData, $product, $cloud );
		$usageQuota = $this->articleDraftUsageQuota( $cloud );
		$confirmed  = self::requestString( $requestData, 'affilun_article_draft_confirm' ) === '1';
		$this->renderArticleDraftOptionsForm( $product, $options, $confirmed, $usageQuota );

		if ( ! $confirmed ) {
			echo '<div class="notice notice-info inline"><p>'
				. esc_html( '生成設定を確認してから、affilun Cloud で記事下書きを生成してください。' )
				. '</p></div>';
			echo '</div>';
			return;
		}

		try {
			$draft = $this->articleDraftClient->generate(
				$cloud,
				self::articleDraftPayload( $product, $options ),
				time(),
				bin2hex( random_bytes( 12 ) )
			);
		} catch ( \Throwable $unused ) {
			unset( $unused );
			echo '<div class="notice notice-error inline"><p>'
				. esc_html( 'affilun Cloud の記事下書き作成に失敗しました。しばらく待ってからもう一度お試しください。' )
				. '</p></div>';
			echo '</div>';
			return;
		}

		$this->renderArticleDraftResult( $product, $draft );
		echo '</div>';
	}

	/**
	 * Render article-draft option controls for first generation or regeneration.
	 *
	 * @param ProductRecord               $product    Product record.
	 * @param array<string,mixed>         $options    Current generation options.
	 * @param bool                        $confirmed  Whether generation has been confirmed.
	 * @param array<string,int|null>|null $usageQuota Current article-draft usage/quota.
	 */
	private function renderArticleDraftOptionsForm( ProductRecord $product, array $options, bool $confirmed, ?array $usageQuota ): void {
		$id = $product->id();
		if ( ! is_int( $id ) || $id <= 0 ) {
			return;
		}

		echo '<div class="affilun-article-draft-options" style="background:#fff;border:1px solid #ccd0d4;padding:12px;margin:0 0 12px;">';
		echo '<h3 style="margin-top:0;">' . esc_html( '生成設定' ) . '</h3>';
		$this->renderArticleDraftUsageQuota( $usageQuota );
		echo '<form method="post" action="' . esc_url( self::productsListAdminUrl() ) . '">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';
		echo '<input type="hidden" name="affilun_article_draft_product_id" value="' . esc_attr( (string) $id ) . '">';
		echo '<input type="hidden" name="affilun_article_draft_confirm" value="1">';
		$this->renderArticleDraftDefaultSummary( $options );
		self::renderNonceField( self::articleDraftNonceAction( $id ), '_wpnonce' );
		$buttonLabel = $confirmed ? 'この設定で再生成' : 'この設定で記事下書きを生成';
		echo '<p><button type="submit" class="button button-primary">'
			. esc_html( $buttonLabel )
			. '</button></p>';
		echo '</form>';
		echo '</div>';
	}

	/**
	 * Render current article-draft quota status.
	 *
	 * @param array<string,int|null>|null $usageQuota Current usage/quota.
	 */
	private function renderArticleDraftUsageQuota( ?array $usageQuota ): void {
		if ( $usageQuota === null ) {
			echo '<p class="description">'
				. esc_html( '記事下書きの今月の利用状況は取得できませんでした。' )
				. '</p>';
			return;
		}

		$used      = $usageQuota['used'] ?? null;
		$limit     = $usageQuota['limit'] ?? null;
		$remaining = $usageQuota['remaining'] ?? null;
		if ( $limit === null ) {
			$message = sprintf( '記事下書き生成: 今月 %d 件利用済み / 上限なし', (int) $used );
		} else {
			$message = sprintf(
				'記事下書き生成: 今月 %d / %d 件利用済み（残り %d 件）',
				(int) $used,
				(int) $limit,
				(int) max( 0, (int) $remaining )
			);
		}

		echo '<p class="description">' . esc_html( $message ) . '</p>';
	}

	/**
	 * Resolve current article-draft Cloud usage/quota.
	 *
	 * @param CloudSettings $cloud Cloud settings.
	 *
	 * @return array<string,int|null>|null
	 */
	private function articleDraftUsageQuota( CloudSettings $cloud ): ?array {
		try {
			$usage = $this->usageClient->current( $cloud, time(), bin2hex( random_bytes( 12 ) ) );
		} catch ( \Throwable $unused ) {
			unset( $unused );
			return null;
		}

		$metrics = is_array( $usage['metrics'] ?? null ) ? $usage['metrics'] : array();
		foreach ( $metrics as $metric ) {
			if ( ! is_array( $metric ) || ( $metric['metric'] ?? null ) !== 'ai.article_draft' ) {
				continue;
			}

			return array(
				'used'      => isset( $metric['used'] ) && is_numeric( $metric['used'] ) ? (int) $metric['used'] : 0,
				'limit'     => isset( $metric['limit'] ) && is_numeric( $metric['limit'] ) ? (int) $metric['limit'] : null,
				'remaining' => isset( $metric['remaining'] ) && is_numeric( $metric['remaining'] ) ? (int) $metric['remaining'] : null,
			);
		}

		return null;
	}

	/**
	 * Render generated article draft result.
	 *
	 * @param ProductRecord       $product Product record.
	 * @param array<string,mixed> $draft   Draft data.
	 */
	private function renderArticleDraftResult( ProductRecord $product, array $draft ): void {
		$title   = is_scalar( $draft['title'] ?? null ) ? (string) $draft['title'] : '記事下書き';
		$excerpt = is_scalar( $draft['excerpt'] ?? null ) ? (string) $draft['excerpt'] : '';
		$body    = is_scalar( $draft['body_markdown'] ?? null ) ? (string) $draft['body_markdown'] : '';

		echo '<div class="notice notice-success inline"><p>'
			. esc_html( sprintf( '%s の記事下書きを作成しました。公開前に内容を確認・編集してください。', $product->title() ) )
			. '</p></div>';
		echo '<div class="affilun-article-draft-result" style="background:#fff;border:1px solid #ccd0d4;padding:16px;">';
		$this->renderCreatePostDraftForm( $product, $title, $excerpt, $body );
		$this->renderArticleDraftSources( $draft['sources'] ?? array() );
		echo '</div>';
	}

	/**
	 * Render a form that creates a WordPress post draft from generated text.
	 *
	 * @param ProductRecord $product Product record.
	 * @param string        $title   Draft title.
	 * @param string        $excerpt Draft excerpt.
	 * @param string        $body    Draft Markdown body.
	 */
	private function renderCreatePostDraftForm( ProductRecord $product, string $title, string $excerpt, string $body ): void {
		$id = $product->id();
		if ( ! is_int( $id ) || $id <= 0 ) {
			return;
		}

		echo '<form method="post" action="' . esc_url( self::resolveAdminUrl( 'admin-post.php' ) ) . '" style="margin-top:12px;">';
		echo '<input type="hidden" name="action" value="affilun_create_article_draft">';
		echo '<input type="hidden" name="product_id" value="' . esc_attr( (string) $id ) . '">';
		echo '<p><label for="affilun-generated-article-title"><strong>' . esc_html( '投稿タイトル' ) . '</strong></label></p>';
		echo '<input id="affilun-generated-article-title" class="large-text" type="text" name="article_title" value="'
			. esc_attr( $title ) . '">';
		echo '<p><label for="affilun-generated-article-excerpt"><strong>' . esc_html( '抜粋' ) . '</strong></label></p>';
		echo '<textarea id="affilun-generated-article-excerpt" class="large-text" rows="3" name="article_excerpt">';
		echo esc_textarea( $excerpt );
		echo '</textarea>';
		echo '<p><label for="affilun-generated-article-draft"><strong>' . esc_html( '生成された下書き' ) . '</strong></label></p>';
		echo '<textarea id="affilun-generated-article-draft" class="large-text code" rows="18" name="article_body">';
		echo esc_textarea( $body );
		echo '</textarea>';
		echo '<p class="description">' . esc_html( '必要に応じてタイトル・抜粋・本文を編集してから、WordPress投稿下書きとして保存してください。' ) . '</p>';
		self::renderNonceField( self::createArticleDraftNonceAction( $id ), '_affilun_create_article_draft_nonce' );
		echo '<button type="submit" class="button button-primary">'
			. esc_html( 'WordPress投稿下書きとして保存' )
			. '</button>';
		echo '</form>';
	}

	/**
	 * Render article-draft source status list.
	 *
	 * @param mixed $sources Sources payload.
	 */
	private function renderArticleDraftSources( mixed $sources ): void {
		if ( ! is_array( $sources ) || $sources === array() ) {
			return;
		}
		echo '<details style="margin-top:12px;"><summary>' . esc_html( '参照ソース' ) . '</summary><ul>';
		foreach ( $sources as $source ) {
			if ( ! is_array( $source ) ) {
				continue;
			}
			$url    = is_scalar( $source['url'] ?? null ) ? (string) $source['url'] : '';
			$status = is_scalar( $source['status'] ?? null ) ? (string) $source['status'] : '';
			if ( $url === '' ) {
				continue;
			}
			echo '<li><code>' . esc_html( $status ) . '</code> <a href="' . esc_url( $url ) . '" target="_blank" rel="noopener noreferrer">'
				. esc_html( $url ) . '</a></li>';
		}
		echo '</ul></details>';
	}

	/**
	 * Render article-draft defaults used for generation.
	 *
	 * @param array<string,mixed> $options Current generation options.
	 */
	private function renderArticleDraftDefaultSummary( array $options ): void {
		$toneLabels = self::ARTICLE_DRAFT_TONES;
		$lengths    = self::ARTICLE_DRAFT_LENGTHS;
		$tone       = is_scalar( $options['tone'] ?? null ) ? (string) $options['tone'] : 'professional_blog';
		$length     = is_scalar( $options['target_length'] ?? null ) ? (string) $options['target_length'] : 'medium';
		$reviewUrls = is_array( $options['review_urls'] ?? null ) ? $options['review_urls'] : array();

		echo '<p class="description">'
			. esc_html( '文体・長さ・参考URLの既定値は affilun Cloud 設定タブで変更できます。' )
			. '</p>';
		echo '<ul style="margin:.5em 0 1em 1.4em;list-style:disc;">';
		echo '<li>' . esc_html( '文体: ' . ( $toneLabels[ $tone ] ?? $tone ) ) . '</li>';
		echo '<li>' . esc_html( '長さ: ' . ( $lengths[ $length ] ?? $length ) ) . '</li>';
		echo '<li>' . esc_html( '参考URL: ' . ( $reviewUrls === array() ? '送信しない' : '商品URLを送信' ) ) . '</li>';
		echo '</ul>';
	}



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

	/**
	 * Build default article-draft generation options.
	 *
	 * @param ProductRecord $product Product record.
	 * @param CloudSettings $cloud   Cloud settings.
	 *
	 * @return array<string,mixed>
	 */
	private static function defaultArticleDraftOptions( ProductRecord $product, CloudSettings $cloud ): array {
		$reviewUrls = $cloud->articleDraftReviewSourcePolicy() === 'product_url'
			? array( $product->detailUrl() )
			: array();
		return array(
			'tone'          => $cloud->articleDraftTone(),
			'target_length' => $cloud->articleDraftTargetLength(),
			'review_urls'   => $reviewUrls,
		);
	}

	/**
	 * Extract article-draft generation options from request data.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 * @param ProductRecord       $product     Product record.
	 * @param CloudSettings       $cloud       Cloud settings.
	 *
	 * @return array<string,mixed>
	 */
	private static function articleDraftOptionsFromRequest( array $requestData, ProductRecord $product, CloudSettings $cloud ): array {
		$defaults = self::defaultArticleDraftOptions( $product, $cloud );
		$tone     = self::requestString( $requestData, 'affilun_article_draft_tone' ) ?? (string) $defaults['tone'];
		$length   = self::requestString( $requestData, 'affilun_article_draft_target_length' ) ?? (string) $defaults['target_length'];

		if ( ! array_key_exists( $tone, self::ARTICLE_DRAFT_TONES ) ) {
			$tone = (string) $defaults['tone'];
		}
		if ( ! array_key_exists( $length, self::ARTICLE_DRAFT_LENGTHS ) ) {
			$length = (string) $defaults['target_length'];
		}

		return array(
			'tone'          => $tone,
			'target_length' => $length,
			'review_urls'   => self::articleDraftReviewUrlsFromRequest( $requestData, $product, $defaults ),
		);
	}

	/**
	 * Extract safe review-source URLs from request data.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 * @param ProductRecord       $product     Product record.
	 * @param array<string,mixed> $defaults    Default generation options.
	 *
	 * @return list<string>
	 */
	private static function articleDraftReviewUrlsFromRequest( array $requestData, ProductRecord $product, array $defaults ): array {
		$raw = self::rawRequestString( $requestData, 'affilun_article_draft_review_urls' );
		if ( trim( $raw ) === '' ) {
			$defaultUrls = is_array( $defaults['review_urls'] ?? null ) ? $defaults['review_urls'] : array();
			$raw         = implode( "\n", array_map( 'strval', $defaultUrls ) );
		}

		$lines = preg_split( '/\R/u', $raw );
		if ( ! is_array( $lines ) ) {
			$lines = array( $raw );
		}

		$urls = array();
		foreach ( $lines as $line ) {
			$url = trim( (string) $line );
			if ( $url === '' || ! self::isArticleDraftHttpUrl( $url ) || in_array( $url, $urls, true ) ) {
				continue;
			}
			$urls[] = self::truncate( $url, 2048 );
			if ( count( $urls ) >= 5 ) {
				break;
			}
		}

		if ( $urls !== array() ) {
			return $urls;
		}

		return is_array( $defaults['review_urls'] ?? null ) ? $defaults['review_urls'] : array();
	}

	/**
	 * Whether an article-draft source URL is absolute HTTP(S).
	 *
	 * @param string $url Candidate URL.
	 */
	private static function isArticleDraftHttpUrl( string $url ): bool {
		if ( preg_match( '/[\x00-\x1F\x7F\s]/u', $url ) === 1 ) {
			return false;
		}

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

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

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

	/**
	 * Truncate text for Cloud payloads.
	 *
	 * @param string $value Text.
	 * @param int    $max   Max length.
	 */
	private static function truncate( string $value, int $max ): string {
		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
			return mb_strlen( $value ) > $max ? mb_substr( $value, 0, $max ) : $value;
		}

		return strlen( $value ) > $max ? substr( $value, 0, $max ) : $value;
	}

	/**
	 * Read a positive integer request parameter.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 * @param string              $key         Request key.
	 */
	private static function requestPositiveInt( array $requestData, string $key ): ?int {
		$value = self::requestString( $requestData, $key );
		if ( $value === null || ! ctype_digit( $value ) ) {
			return null;
		}
		$parsed = (int) $value;
		return $parsed > 0 ? $parsed : null;
	}

	/**
	 * Read a page-size request parameter restricted to the exposed choices.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 * @param string              $key         Request key.
	 * @param int                 $fallback    Fallback page size.
	 */
	private static function requestPerPage( array $requestData, string $key, int $fallback ): int {
		$value = self::requestPositiveInt( $requestData, $key );
		if ( $value !== null && in_array( $value, self::PER_PAGE_OPTIONS, true ) ) {
			return $value;
		}

		return $fallback;
	}

	/**
	 * Build nonce action for article-draft generation.
	 *
	 * @param int $productId Product id.
	 */
	private static function articleDraftNonceAction( int $productId ): string {
		return 'affilun_article_draft_' . (string) $productId;
	}

	/**
	 * Build nonce action for creating a WordPress article draft.
	 *
	 * @param int $productId Product id.
	 */
	public static function createArticleDraftNonceAction( int $productId ): string {
		return 'affilun_create_article_draft_' . (string) $productId;
	}

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

	/**
	 * Verify a nonce when WordPress nonce helpers are available.
	 *
	 * @param string $nonce  Nonce.
	 * @param string $action Action.
	 */
	private static function verifyNonce( string $nonce, string $action ): bool {
		if ( $nonce === '' || ! function_exists( 'wp_verify_nonce' ) ) {
			return false;
		}

		if ( function_exists( 'sanitize_text_field' ) ) {
			$nonce = (string) sanitize_text_field( $nonce );
		}

		return (bool) wp_verify_nonce( $nonce, $action );
	}

	/**
	 * Extract a raw scalar string from request data.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 * @param string              $key         Request key.
	 */
	private static function rawRequestString( array $requestData, string $key ): string {
		if ( ! array_key_exists( $key, $requestData ) || ! is_scalar( $requestData[ $key ] ) ) {
			return '';
		}

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

	/**
	 * Render bulk filter controls.
	 *
	 * @param array<string,string> $filters Current filters.
	 */
	private function renderBulkFilterControls( array $filters ): void {
		echo '<fieldset><legend>' . esc_html( '対象条件' ) . '</legend>';
		echo '<label>' . esc_html( 'プロバイダー' ) . ' ';
		echo '<select name="affilun_bulk_provider"><option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( self::PROVIDERS as $key => $label ) {
			$selected = ( $filters['provider'] ?? '' ) === $key ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $key ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is hard-coded.
				. $selected . '>' . esc_html( $label ) . '</option>';
		}
		echo '</select></label> ';

		echo '<label>' . esc_html( '状態' ) . ' ';
		echo '<select name="affilun_bulk_lifecycle_filter"><option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( ProductListQuery::validLifecycleStates() as $state ) {
			$selected = ( $filters['lifecycle_state'] ?? '' ) === $state ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $state ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is hard-coded.
				. $selected . '>' . esc_html( $state ) . '</option>';
		}
		echo '</select></label> ';

		echo '</fieldset>';
	}

	/**
	 * Render bulk change controls.
	 *
	 * @param array<string,mixed> $changes Current changes.
	 */
	private function renderBulkChangeControls( array $changes ): void {
		echo '<fieldset style="margin-top:8px;"><legend>' . esc_html( '適用内容' ) . '</legend>';
		echo '<label>' . esc_html( '状態変更' ) . ' ';
		echo '<select name="affilun_bulk_lifecycle_change"><option value="">' . esc_html( '変更しない' ) . '</option>';
		foreach ( ProductListQuery::validLifecycleStates() as $state ) {
			$selected = ( $changes['lifecycle_state'] ?? '' ) === $state ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $state ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is hard-coded.
				. $selected . '>' . esc_html( $state ) . '</option>';
		}
		echo '</select></label><br>';

		echo '</fieldset>';
	}

	/**
	 * Render bulk preview and confirmation form.
	 *
	 * @param array<string,string> $filters Filters.
	 * @param array<string,mixed>  $changes Changes.
	 */
	private function renderBulkPreview( array $filters, array $changes ): void {
		if ( $this->bulkUseCase === null ) {
			return;
		}

		try {
			$result = $this->bulkUseCase->preview( $filters, $changes );
		} catch ( \Throwable $unused ) {
			unset( $unused );
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( '一括編集の入力内容を確認してください。' );
			echo '</p></div>';
			return;
		}

		echo '<div class="notice notice-warning inline"><p>';
		echo esc_html( sprintf( '%d 件の商品が対象です。内容を確認してから適用してください。', $result->matchedCount() ) );
		echo '</p></div>';
		$this->renderBulkPreviewList( $result );

		if ( $result->matchedCount() < 1 ) {
			return;
		}

		echo '<form method="post" action="' . esc_url( self::resolveAdminUrl( 'admin-post.php' ) ) . '">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::BULK_ACTION ) . '">';
		self::renderHiddenBulkFields( $filters, $changes );
		self::renderNonceField( self::bulkNonceAction( $filters, $changes ), self::BULK_NONCE_FIELD );
		echo '<button type="submit" class="button button-primary" onclick="return confirm(\''
			. esc_attr( 'プレビューした条件で一括更新しますか？' )
			. '\');">' . esc_html( 'この内容で一括更新' ) . '</button>';
		echo '</form>';
	}

	/**
	 * Render preview sample list.
	 *
	 * @param BulkUpdateProductsResult $result Preview result.
	 */
	private function renderBulkPreviewList( BulkUpdateProductsResult $result ): void {
		$records = $result->previewRecords();
		if ( $records === array() ) {
			return;
		}

		echo '<ul style="margin-left:1.4em;list-style:disc;">';
		foreach ( $records as $record ) {
			$id    = $record->id();
			$label = sprintf( '#%s %s', $id === null ? '-' : (string) $id, $record->title() );
			echo '<li>' . esc_html( $label ) . '</li>';
		}
		echo '</ul>';
	}

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

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

	/**
	 * Render hidden bulk fields for confirmation.
	 *
	 * @param array<string,string> $filters Filters.
	 * @param array<string,mixed>  $changes Changes.
	 */
	private static function renderHiddenBulkFields( array $filters, array $changes ): void {
		$hidden = array(
			'affilun_bulk_provider'         => $filters['provider'] ?? '',
			'affilun_bulk_lifecycle_filter' => $filters['lifecycle_state'] ?? '',
			'affilun_bulk_lifecycle_change' => is_scalar( $changes['lifecycle_state'] ?? null )
				? (string) $changes['lifecycle_state']
				: '',
		);

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

	/**
	 * Extract bulk filters from GET/POST-like data.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 *
	 * @return array<string,string>
	 */
	private static function bulkFiltersFromRequest( array $requestData ): array {
		return array(
			'provider'        => self::requestString( $requestData, 'affilun_bulk_provider' ) ?? '',
			'lifecycle_state' => self::requestString( $requestData, 'affilun_bulk_lifecycle_filter' ) ?? '',
		);
	}

	/**
	 * Extract bulk changes from GET/POST-like data.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 *
	 * @return array<string,mixed>
	 */
	private static function bulkChangesFromRequest( array $requestData ): array {
		$changes   = array();
		$lifecycle = self::requestString( $requestData, 'affilun_bulk_lifecycle_change' );
		if ( $lifecycle !== null ) {
			$changes['lifecycle_state'] = $lifecycle;
		}
		return $changes;
	}

	/**
	 * Extract a sanitized scalar string from request data.
	 *
	 * @param array<string,mixed> $requestData Request data.
	 * @param string              $key         Request key.
	 */
	private static function requestString( array $requestData, string $key ): ?string {
		if ( ! array_key_exists( $key, $requestData ) || ! is_scalar( $requestData[ $key ] ) ) {
			return null;
		}

		$value = (string) $requestData[ $key ];
		if ( function_exists( 'sanitize_text_field' ) ) {
			$value = (string) sanitize_text_field( $value );
		}
		$value = trim( $value );

		return $value === '' ? null : $value;
	}

	/**
	 * Render search form.
	 *
	 * @param ProductListQuery $query Current query.
	 */
	private function renderSearchForm( ProductListQuery $query ): void {
		echo '<form method="get" action="" class="affilun-products-search-form">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';
		$this->renderFilterHiddenInputs( $query, false );
		echo '<label class="screen-reader-text" for="affilun-filter-search">' . esc_html( 'リンクを検索' ) . '</label>';
		echo '<input type="search" name="' . esc_attr( ProductsListQueryParser::KEY_SEARCH ) . '"';
		echo ' id="affilun-filter-search" spellcheck="false" autocorrect="off"';
		echo ' autocapitalize="off" autocomplete="off"';
		echo ' value="' . esc_attr( $query->search() ?? '' ) . '"> ';
		echo '<button type="submit" class="button">' . esc_html( 'リンクを検索' ) . '</button>';
		echo '</form>';
	}

	/**
	 * Render the §6.4 filter form.
	 *
	 * @param ProductListQuery $query Current query.
	 */
	private function renderFilterForm( ProductListQuery $query ): void {
		echo '<form method="get" action="" class="affilun-products-filter-form">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '">';
		$this->renderFilterHiddenInputs( $query, true );

		echo '<div class="alignleft actions affilun-products-filters">';
		echo '<label for="affilun-filter-provider">' . esc_html( 'プロバイダー' ) . '</label> ';
		$this->renderProviderSelect( $query->provider() );
		echo ' ';
		echo '<label for="affilun-filter-sort">' . esc_html( '並び順' ) . '</label> ';
		$this->renderSortSelect( $query->sortKey() );
		echo ' ';
		echo '<label for="affilun-filter-order">' . esc_html( '順番' ) . '</label> ';
		$this->renderOrderSelect( $query->sortOrder() );
		echo ' ';

		echo '<button type="submit" class="button">' . esc_html( '絞り込み' ) . '</button>';
		echo '</div><br class="clear">';

		echo '</form>';
	}

	/**
	 * Render hidden inputs that preserve query state.
	 *
	 * @param ProductListQuery $query         Current query.
	 * @param bool             $includeSearch Whether to include search.
	 */
	private function renderFilterHiddenInputs( ProductListQuery $query, bool $includeSearch ): void {
		$userState = self::userStateFromLifecycle( $query->lifecycleState() );
		if ( $userState !== null ) {
			echo '<input type="hidden" name="' . esc_attr( ProductsListQueryParser::KEY_USER_STATE )
				. '" value="' . esc_attr( $userState ) . '">';
		}
		if ( $query->syncState() !== null ) {
			echo '<input type="hidden" name="' . esc_attr( ProductsListQueryParser::KEY_SYNC_STATE )
				. '" value="' . esc_attr( (string) $query->syncState() ) . '">';
		}
		if ( $query->perPage() !== 20 ) {
			echo '<input type="hidden" name="' . esc_attr( ProductsListQueryParser::KEY_PER_PAGE )
				. '" value="' . esc_attr( (string) $query->perPage() ) . '">';
		}
		if ( $includeSearch && $query->search() !== null ) {
			echo '<input type="hidden" name="' . esc_attr( ProductsListQueryParser::KEY_SEARCH )
				. '" value="' . esc_attr( (string) $query->search() ) . '">';
		}
	}

	/**
	 * Render provider filter select.
	 *
	 * @param string|null $current Current provider filter.
	 */
	private function renderProviderSelect( ?string $current ): void {
		echo '<select name="' . esc_attr( ProductsListQueryParser::KEY_PROVIDER ) . '" id="affilun-filter-provider">';
		echo '<option value="">' . esc_html( 'すべて' ) . '</option>';
		foreach ( self::PROVIDERS as $key => $label ) {
			$selected = $current === $key ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $key ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is hard-coded.
				. $selected . '>' . esc_html( $label ) . '</option>';
		}
		echo '</select>';
	}

	/**
	 * Render sort select.
	 *
	 * @param string $current Current sort key.
	 */
	private function renderSortSelect( string $current ): void {
		$labels = array(
			'updated_at'     => '更新日時',
			'created_at'     => '作成日時',
			'title'          => '商品名',
			'last_synced_at' => '最終同期日時',
		);
		echo '<select name="' . esc_attr( ProductsListQueryParser::KEY_SORT ) . '" id="affilun-filter-sort">';
		foreach ( ProductListQuery::validSortKeys() as $sortKey ) {
			$selected = $current === $sortKey ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $sortKey ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is hard-coded.
				. $selected . '>' . esc_html( $labels[ $sortKey ] ?? $sortKey ) . '</option>';
		}
		echo '</select>';
	}

	/**
	 * Render order select.
	 *
	 * @param string $current Current sort order.
	 */
	private function renderOrderSelect( string $current ): void {
		$labels = array(
			'desc' => '降順',
			'asc'  => '昇順',
		);
		echo '<select name="' . esc_attr( ProductsListQueryParser::KEY_ORDER ) . '" id="affilun-filter-order">';
		foreach ( ProductListQuery::validSortOrders() as $order ) {
			$selected = $current === $order ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $order ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is hard-coded.
				. $selected . '>' . esc_html( $labels[ $order ] ?? $order ) . '</option>';
		}
		echo '</select>';
	}

	/**
	 * Render the §6.2 results-table `<thead>`.
	 *
	 * Phase-8 minimum: ID / Provider / Title / Brand / Price /
	 * Used Posts / Sync State / Last Synced / Updated At / Article Draft / Actions.
	 *
	 */
	private function renderTableHead(): void {
		echo '<thead><tr>';
		echo '<td class="manage-column column-cb check-column"><input type="checkbox" aria-label="'
			. esc_attr( 'すべての商品を選択' ) . '"></td>';
		$headings = array(
			'ID',
			'プロバイダー',
			'商品名',
			'価格',
		);
		$headings = array_merge(
			$headings,
			array(
				'利用ポスト',
				'同期状態',
				'最終同期日時',
				'更新日時',
				'記事下書き',
				'操作',
			)
		);

		foreach ( $headings as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead>';
	}

	/**
	 * Render a single product row by routing the record through
	 * {@see ProductSummaryMapper::map()} for shape normalization
	 * and escaping every visible field.
	 *
	 * @param ProductRecord $record           Persisted record.
	 * @param array         $usagePosts       Posts using this product.
	 * @param bool          $cloudContracted  Whether affilun Cloud is configured.
	 * @param string        $cloudSettingsUrl affilun Cloud settings URL.
	 *
	 * @phpstan-param list<array{id:int,title:string,url:string,type:string,status:string}> $usagePosts
	 */
	private function renderRow( ProductRecord $record, array $usagePosts, bool $cloudContracted, string $cloudSettingsUrl ): void {
		$row = $this->summaryMapper->map( $record, null );

		$id           = $row['id'] ?? null;
		$provider     = (string) ( $row['provider'] ?? '' );
		$title        = (string) ( $row['title'] ?? '' );
		$syncState    = self::syncStateLabel( (string) ( $row['sync_state'] ?? '' ) );
		$lastSyncedAt = $row['last_synced_at'] ?? null;
		$imageUrl     = $record->imagePrimaryUrl();
		$isDeleted    = $record->lifecycleState() === ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING;
		$productUrl   = $record->affiliateUrl() ?? $record->detailUrl();
		$priceLabel   = AdminPriceFormatter::formatProductRecord( $record );
		$titleDisplay = self::truncateText( $title, 64 );

		$updatedAt           = AdminTimeFormatter::format(
			gmdate( 'Y-m-d\TH:i:s\Z', $record->updatedAt()->getTimestamp() )
		);
		$lastSyncedAtDisplay = AdminTimeFormatter::format(
			$lastSyncedAt === null ? null : (string) $lastSyncedAt
		);

		$idDisplay = $id === null ? '' : (string) $id;
		if ( $idDisplay !== '' && $isDeleted ) {
			$idDisplay .= '(削除済み)';
		}

		echo '<tr>';
		echo '<th scope="row" class="check-column">';
		if ( is_int( $id ) && $id > 0 && ! $isDeleted ) {
			echo '<input type="checkbox" name="product_ids[]" form="affilun-products-bulk-form" value="' . esc_attr( (string) $id ) . '" aria-label="'
				. esc_attr( sprintf( '%s を選択', $title ) ) . '">';
		} else {
			echo '&nbsp;';
		}
		echo '</th>';
		echo '<td>' . esc_html( $idDisplay ) . '</td>';
		echo '<td>' . esc_html( $provider ) . '</td>';
		echo '<td>';
		if ( $imageUrl !== null && $imageUrl !== '' ) {
			echo '<img src="' . esc_url( $imageUrl ) . '"'
				. ' alt="" loading="lazy" width="48" height="48"'
				. ' style="width:48px;height:48px;object-fit:contain;'
				. 'vertical-align:middle;margin-right:8px;border:1px solid #e5e5e5;'
				. 'background:#fff;">';
		}
		echo '<a href="' . esc_url( $productUrl ) . '" target="_blank" rel="noopener noreferrer" title="' . esc_attr( $title ) . '">'
			. esc_html( $titleDisplay ) . '</a>';
		echo '</td>';
		echo '<td>' . esc_html( $priceLabel ) . '</td>';
		echo '<td>';
		$this->renderUsagePosts( $usagePosts );
		echo '</td>';
		echo '<td>' . esc_html( $syncState ) . '</td>';
		echo '<td>' . esc_html( $lastSyncedAtDisplay ) . '</td>';
		echo '<td>' . esc_html( $updatedAt ) . '</td>';
		echo '<td>';
		$this->renderArticleDraftAction( $record, $cloudContracted, $cloudSettingsUrl );
		echo '</td>';
		echo '<td>';
		$this->renderEditAction( $id );
		echo ' ';
		$this->renderDeleteAction( $id, $isDeleted );
		echo '</td>';
		echo '</tr>';
	}

	/**
	 * Render products whose latest link-health result needs attention.
	 *
	 * @param ProductListQuery    $query       Current product-list query.
	 * @param array<string,mixed> $requestData Request data.
	 */
	private function renderLinkHealthProblemPanel( ProductListQuery $query, array $requestData ): void {
		if ( $this->productRepository === null || $this->productMetaRepository === null ) {
			return;
		}

		$entries = $this->linkHealthProblemEntries();
		if ( $entries === array() ) {
			return;
		}

		$totalEntries = count( $entries );
		$perPage      = self::requestPerPage( $requestData, self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM, self::LINK_HEALTH_PROBLEM_PER_PAGE );
		$totalPages   = max( 1, (int) ceil( $totalEntries / $perPage ) );
		$currentPage  = self::requestPositiveInt( $requestData, self::LINK_HEALTH_PROBLEM_PAGE_PARAM ) ?? 1;
		$currentPage  = min( max( 1, $currentPage ), $totalPages );
		$pageEntries  = array_slice(
			$entries,
			( $currentPage - 1 ) * $perPage,
			$perPage
		);

		echo '<div class="affilun-products-list-card affilun-link-health-problem-panel" id="affilun-link-health-problems">';
		echo '<h2>' . esc_html( '修正が必要な商品' ) . '</h2>';
		echo '<p class="description">';
		echo esc_html(
			sprintf(
				'dailyの自動リンク監視で broken / warning になった保存商品を最大 %d 件まで検出し、%d 件ずつ表示しています。',
				self::LINK_HEALTH_PROBLEM_SCAN_LIMIT,
				$perPage
			)
		);
		echo '</p>';
		echo '<table class="widefat striped">';
		echo '<thead><tr>';
		foreach ( array( '商品ID', 'プロバイダー', '商品名', '状態', '確認日時' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead><tbody>';
		foreach ( $pageEntries as $entry ) {
			$record = $entry['record'];
			$data   = $entry['data'];
			$id     = $record->id();
			echo '<tr>';
			echo '<td>' . esc_html( $id === null ? '' : (string) $id ) . '</td>';
			echo '<td>' . esc_html( $record->provider() ) . '</td>';
			echo '<td><a href="' . esc_url( $record->affiliateUrl() ?? $record->detailUrl() ) . '" target="_blank" rel="noopener noreferrer">'
				. esc_html( $record->title() ) . '</a></td>';
			echo '<td>';
			$this->renderLinkHealthStatusPayload( $data );
			echo '</td>';
			echo '<td>' . esc_html( self::linkHealthCheckedAtLabel( $data ) ) . '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
		$this->renderLinkHealthProblemPagination( $query, $requestData, $currentPage, $totalPages, $totalEntries, $perPage );
		echo '</div>';
	}

	/**
	 * Render pagination for the link-health problem panel.
	 *
	 * @param ProductListQuery    $query        Current product-list query.
	 * @param array<string,mixed> $requestData  Request data.
	 * @param int                 $currentPage  Current problem-panel page.
	 * @param int                 $totalPages   Total problem-panel pages.
	 * @param int                 $totalEntries Total problem entries.
	 * @param int                 $perPage      Current page size.
	 */
	private function renderLinkHealthProblemPagination(
		ProductListQuery $query,
		array $requestData,
		int $currentPage,
		int $totalPages,
		int $totalEntries,
		int $perPage
	): void {
		$firstItem = ( ( $currentPage - 1 ) * $perPage ) + 1;
		$lastItem  = min( $totalEntries, $currentPage * $perPage );

		echo '<div class="tablenav"><div class="tablenav-pages">';
		echo '<span class="displaying-num">';
		echo esc_html( sprintf( '%d件中 %d-%d 件', $totalEntries, $firstItem, $lastItem ) );
		echo '</span>';
		$this->renderPerPageLinks(
			$perPage,
			static fn ( int $option ): string => self::buildLinkHealthProblemPageUrlWithPerPage( 1, $option, $query, $requestData )
		);

		if ( $totalPages > 1 ) {
			echo ' <span class="pagination-links">';
			if ( $currentPage > 1 ) {
				echo '<a class="prev-page button" href="'
					. esc_url( self::buildLinkHealthProblemPageUrl( $currentPage - 1, $query, $requestData ) )
					. '">' . esc_html( '« 前へ' ) . '</a> ';
			}
			echo '<span class="paging-input">' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . '</span> ';
			if ( $currentPage < $totalPages ) {
				echo '<a class="next-page button" href="'
					. esc_url( self::buildLinkHealthProblemPageUrl( $currentPage + 1, $query, $requestData ) )
					. '">' . esc_html( '次へ »' ) . '</a>';
			}
			echo '</span>';
		} else {
			echo ' <span class="paging-input">' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . '</span>';
		}

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

	/**
	 * Render 10/20/50 page-size links.
	 *
	 * @param int                  $current    Current page size.
	 * @param callable(int):string $urlBuilder URL builder for an option.
	 */
	private function renderPerPageLinks( int $current, callable $urlBuilder ): void {
		echo ' <span class="affilun-per-page-links">';
		echo esc_html( '表示件数: ' );
		$first = true;
		foreach ( self::PER_PAGE_OPTIONS as $option ) {
			if ( ! $first ) {
				echo ' / ';
			}
			if ( $option === $current ) {
				echo '<strong>' . esc_html( sprintf( '%d件', $option ) ) . '</strong>';
			} else {
				echo '<a href="' . esc_url( $urlBuilder( $option ) ) . '">' . esc_html( sprintf( '%d件', $option ) ) . '</a>';
			}
			$first = false;
		}
		echo '</span>';
	}

	/**
	 * Truncate visible table text without changing the link target/title.
	 *
	 * @param string $text  Text.
	 * @param int    $limit Maximum visible length.
	 */
	private static function truncateText( string $text, int $limit ): string {
		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
			return mb_strlen( $text, 'UTF-8' ) > $limit
				? mb_substr( $text, 0, $limit, 'UTF-8' ) . '…'
				: $text;
		}

		return strlen( $text ) > $limit ? substr( $text, 0, $limit ) . '...' : $text;
	}

	/**
	 * Find active saved products whose latest link-health status needs attention.
	 *
	 * @return list<array{record:ProductRecord,data:array<string,mixed>}>
	 */
	private function linkHealthProblemEntries(): array {
		if ( $this->productRepository === null || $this->productMetaRepository === null ) {
			return array();
		}

		$store   = new ProductLinkHealthStatusStore( $this->productMetaRepository );
		$query   = ProductListQuery::defaults()
			->withLifecycleState( ProductListQuery::LIFECYCLE_STATE_ACTIVE )
			->withPerPage( 50 );
		$page    = 1;
		$scanned = 0;
		$entries = array();

		do {
			$result = $this->productRepository->findPaginated( $query->withPage( $page ) );
			foreach ( $result->items() as $record ) {
				++$scanned;
				$id = $record->id();
				if ( $id === null ) {
					continue;
				}
				$data = $store->readForProduct( $id );
				if ( $data === null ) {
					continue;
				}
				$bucket = self::linkHealthStatusClassFromData( $data );
				if ( $bucket === 'broken' || $bucket === 'warning' ) {
					$entries[] = array(
						'record' => $record,
						'data'   => $data,
					);
				}
			}
			++$page;
		} while ( $page <= $result->totalPages() && $scanned < self::LINK_HEALTH_PROBLEM_SCAN_LIMIT );

		return $entries;
	}

	/**
	 * Render a link-health status badge from a persisted payload.
	 *
	 * @param array<string,mixed> $data Link-health data.
	 */
	private function renderLinkHealthStatusPayload( array $data ): void {
		$status     = is_scalar( $data['status'] ?? null ) ? (string) $data['status'] : 'unknown';
		$httpStatus = isset( $data['http_status'] ) && is_numeric( $data['http_status'] ) ? (int) $data['http_status'] : 0;
		$label      = $httpStatus > 0 ? sprintf( '%s / HTTP %d', $status, $httpStatus ) : $status;

		echo '<span class="affilun-link-health-status affilun-link-health-status--'
			. esc_attr( self::linkHealthStatusClassFromData( $data ) )
			. '">' . esc_html( $label ) . '</span>';
	}

	/**
	 * Format the checked_at value from a link-health payload.
	 *
	 * @param array<string,mixed> $data Link-health data.
	 */
	private static function linkHealthCheckedAtLabel( array $data ): string {
		$checkedAt = is_scalar( $data['checked_at'] ?? null ) ? (string) $data['checked_at'] : '';
		return $checkedAt === '' ? '' : AdminTimeFormatter::format( $checkedAt );
	}

	/**
	 * Resolve CSS class suffix for link-health data.
	 *
	 * @param array<string,mixed> $data Link-health data.
	 */
	private static function linkHealthStatusClassFromData( array $data ): string {
		$status     = is_scalar( $data['status'] ?? null ) ? (string) $data['status'] : '';
		$httpStatus = isset( $data['http_status'] ) && is_numeric( $data['http_status'] ) ? (int) $data['http_status'] : 0;
		return LinkHealthStatusClassifier::classify( $status, $httpStatus );
	}

	/**
	 * Render posts that use the product.
	 *
	 * @param array $usagePosts Posts using this product.
	 *
	 * @phpstan-param list<array{id:int,title:string,url:string,type:string,status:string}> $usagePosts
	 */
	private function renderUsagePosts( array $usagePosts ): void {
		if ( $usagePosts === array() ) {
			echo '&mdash;';
			return;
		}

		echo '<ul style="margin:0;padding-left:1.2em;">';
		foreach ( $usagePosts as $post ) {
			$label = sprintf( '%s (#%d)', $post['title'], $post['id'] );
			echo '<li><a href="' . esc_url( $post['url'] ) . '">'
				. esc_html( $label ) . '</a>';
			if ( $post['status'] !== '' ) {
				echo ' <span class="description">' . esc_html( $post['status'] ) . '</span>';
			}
			echo '</li>';
		}
		echo '</ul>';
	}

	/**
	 * Render the affilun Cloud article-draft action for one row.
	 *
	 * @param ProductRecord $record           Product record.
	 * @param bool          $cloudContracted  Whether affilun Cloud is contracted.
	 * @param string        $cloudSettingsUrl affilun Cloud settings URL.
	 */
	private function renderArticleDraftAction( ProductRecord $record, bool $cloudContracted, string $cloudSettingsUrl ): void {
		$id = $record->id();
		if ( ! is_int( $id ) || $id <= 0 ) {
			echo '&mdash;';
			return;
		}

		if ( ! $cloudContracted ) {
			echo '<button type="button" class="button button-small" disabled aria-disabled="true" title="'
				. esc_attr( '記事下書き機能の利用にはaffilun cloudの契約が必要です。' )
				. '" style="opacity:.55;cursor:not-allowed;">'
				. esc_html( 'AI記事生成' )
				. '</button>';
			echo '<p class="description" style="margin:.35em 0 0;">'
				. esc_html( '記事下書き機能の利用にはaffilun cloudの契約が必要です。' )
				. '<br><a href="' . esc_url( $cloudSettingsUrl ) . '">'
				. esc_html( 'affilun Cloud設定へ' )
				. '</a></p>';
			return;
		}

		echo '<form method="post" action="' . esc_url( self::resolveAdminUrl( 'admin-post.php' ) ) . '" style="margin:0;" onsubmit="var b=this.querySelector(\'button[type=submit]\'); if(b){b.disabled=true;b.textContent=\'生成中...\';}">';
		echo '<input type="hidden" name="action" value="affilun_generate_article_draft">';
		echo '<input type="hidden" name="product_id" value="' . esc_attr( (string) $id ) . '">';
		self::renderNonceField( self::createArticleDraftNonceAction( $id ), '_affilun_generate_article_draft_nonce' );
		echo '<button type="submit" class="button button-small" aria-label="'
			. esc_attr( sprintf( '%s のAI記事を生成', $record->title() ) )
			. '">'
			. esc_html( 'AI記事生成' )
			. '</button>';
		echo '</form>';
	}

	/**
	 * Render edit action for one row.
	 *
	 * @param mixed $id Product id from the mapped row.
	 */
	private function renderEditAction( mixed $id ): void {
		if ( ! is_int( $id ) || $id <= 0 ) {
			echo '&mdash;';
			return;
		}

		$url = self::productEditUrl( $id );
		echo '<a class="button button-small" href="' . esc_url( $url ) . '">' . esc_html( '編集' ) . '</a>';
	}

	/**
	 * Product edit admin URL with the read-only routing nonce.
	 *
	 * @param int $productId Product id.
	 */
	private static function productEditUrl( int $productId ): string {
		$url = self::resolveAdminUrl( 'admin.php?page=affilun-edit-product&product_id=' . rawurlencode( (string) $productId ) );
		if ( function_exists( 'add_query_arg' ) && function_exists( 'wp_create_nonce' ) ) {
			$url = add_query_arg(
				array(
					'_affilun_edit_product_nonce' => wp_create_nonce( 'affilun_edit_product_' . $productId ),
				),
				$url
			);
		}
		return $url;
	}

	/**
	 * Render the logical-delete action for one row.
	 *
	 * @param mixed $id        Product id from the mapped row.
	 * @param bool  $isDeleted Whether the row is already logically deleted.
	 */
	private function renderDeleteAction( mixed $id, bool $isDeleted ): void {
		if ( ! is_int( $id ) || $id <= 0 ) {
			echo '&mdash;';
			return;
		}

		if ( $isDeleted ) {
			echo '<button type="submit" name="single_delete_id" form="affilun-products-bulk-form" value="' . esc_attr( (string) $id ) . '"'
				. ' class="button button-small button-link-delete" onclick="return confirm(\''
				. esc_attr( 'この商品を完全に削除しますか？この操作は取り消せません。' )
				. '\');">' . esc_html( '完全に削除' ) . '</button>';
			return;
		}

		echo '<button type="submit" name="single_trash_id" form="affilun-products-bulk-form" value="' . esc_attr( (string) $id ) . '"'
			. ' class="button button-small" onclick="return confirm(\''
			. esc_attr( 'この商品をゴミ箱へ移動しますか？' )
			. '\');">' . esc_html( 'ゴミ箱へ移動' ) . '</button>';
	}

	/**
	 * Emit a WordPress nonce field when available.
	 *
	 * @param string $action Nonce action.
	 * @param string $name   Nonce field name.
	 */
	private static function renderNonceField( 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="">';
	}

	/**
	 * Render the §6.7 empty-state message.
	 *
	 * Phase-8 minimum carries no "Add New Product" CTA — the
	 * Add New page lands in slice 8e or Phase 9.
	 */
	private function renderEmptyState(): void {
		echo '<div class="affilun-admin-empty-state">';
		echo '<p>' . esc_html( '商品が見つかりませんでした。' ) . '</p>';
		echo '<p><a class="button button-primary" href="'
			. esc_url( self::resolveAdminUrl( 'admin.php?page=affilun-add-new' ) )
			. '">' . esc_html( '最初の商品を追加' ) . '</a></p>';
		echo '</div>';
	}

	/**
	 * Render the textual `Page X of Y` pagination nav with prev /
	 * next anchors that preserve the current filter state.
	 *
	 * @param ProductListQuery $query      Originating query.
	 * @param int              $totalPages Total page count.
	 * @param int              $totalItems Total item count.
	 */
	private function renderPagination( ProductListQuery $query, int $totalPages, int $totalItems ): void {
		$current = $query->page();

		echo '<div class="tablenav"><div class="tablenav-pages">';
		echo '<span class="displaying-num">';
		echo esc_html( sprintf( '%d件', $totalItems ) );
		echo '</span>';
		$this->renderPerPageLinks(
			$query->perPage(),
			static fn ( int $option ): string => self::buildPageUrl( 1, $query->withPerPage( $option ) )
		);

		if ( $totalPages > 1 ) {
			echo ' <span class="pagination-links">';

			if ( $current > 1 ) {
				$prevUrl = self::buildPageUrl( $current - 1, $query );
				echo '<a class="prev-page button" href="' . esc_url( $prevUrl ) . '">'
					. esc_html( '« 前へ' ) . '</a> ';
			}

			echo '<span class="paging-input">' . esc_html( sprintf( '%d / %d', $current, max( 1, $totalPages ) ) ) . '</span> ';

			if ( $current < $totalPages ) {
				$nextUrl = self::buildPageUrl( $current + 1, $query );
				echo '<a class="next-page button" href="' . esc_url( $nextUrl ) . '">'
					. esc_html( '次へ »' ) . '</a>';
			}

			echo '</span>';
		} else {
			echo ' <span class="paging-input">' . esc_html( sprintf( '%d / %d', $current, max( 1, $totalPages ) ) ) . '</span>';
		}

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

	/**
	 * Build a products-list URL for a query.
	 *
	 * @param ProductListQuery $query Query to serialize.
	 */
	private static function buildFilteredUrl( ProductListQuery $query ): string {
		$args = array(
			'page'                            => self::MENU_SLUG,
			ProductsListQueryParser::KEY_PAGE => $query->page(),
		);
		if ( $query->provider() !== null ) {
			$args[ ProductsListQueryParser::KEY_PROVIDER ] = $query->provider();
		}
		if ( $query->syncState() !== null ) {
			$args[ ProductsListQueryParser::KEY_SYNC_STATE ] = $query->syncState();
		}
		if ( $query->search() !== null ) {
			$args[ ProductsListQueryParser::KEY_SEARCH ] = $query->search();
		}
		$args[ ProductsListQueryParser::KEY_SORT ]  = $query->sortKey();
		$args[ ProductsListQueryParser::KEY_ORDER ] = $query->sortOrder();
		if ( $query->perPage() !== 20 ) {
			$args[ ProductsListQueryParser::KEY_PER_PAGE ] = $query->perPage();
		}
		$userState = self::userStateFromLifecycle( $query->lifecycleState() );
		if ( $userState !== null ) {
			$args[ ProductsListQueryParser::KEY_USER_STATE ] = $userState;
		}

		$base = self::resolveAdminUrl( 'admin.php' );
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $base );
		}

		return $base . ( str_contains( $base, '?' ) ? '&' : '?' ) . http_build_query( $args );
	}

	/**
	 * Build the per-page URL preserving the current filter state.
	 *
	 * Prefers `add_query_arg()` (the WordPress helper handles
	 * existing query-string fragments correctly) and falls back to
	 * manual `http_build_query()` concatenation in pure-unit
	 * harnesses where WordPress is not loaded.
	 *
	 * @param int              $page  Target page (>= 1).
	 * @param ProductListQuery $query Current query state.
	 */
	private static function buildPageUrl( int $page, ProductListQuery $query ): string {
		$args = array(
			'page'                            => self::MENU_SLUG,
			ProductsListQueryParser::KEY_PAGE => $page,
		);

		if ( $query->provider() !== null ) {
			$args[ ProductsListQueryParser::KEY_PROVIDER ] = $query->provider();
		}
		if ( $query->syncState() !== null ) {
			$args[ ProductsListQueryParser::KEY_SYNC_STATE ] = $query->syncState();
		}
		if ( $query->search() !== null ) {
			$args[ ProductsListQueryParser::KEY_SEARCH ] = $query->search();
		}
		$args[ ProductsListQueryParser::KEY_SORT ]  = $query->sortKey();
		$args[ ProductsListQueryParser::KEY_ORDER ] = $query->sortOrder();
		if ( $query->perPage() !== 20 ) {
			$args[ ProductsListQueryParser::KEY_PER_PAGE ] = $query->perPage();
		}
		$userState = self::userStateFromLifecycle( $query->lifecycleState() );
		if ( $userState !== null ) {
			$args[ ProductsListQueryParser::KEY_USER_STATE ] = $userState;
		}

		$base = self::resolveAdminUrl( 'admin.php' );

		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $base );
		}

		$separator = str_contains( $base, '?' ) ? '&' : '?';
		return $base . $separator . http_build_query( $args );
	}

	/**
	 * Build a link-health problem panel page URL while preserving product-list filters.
	 *
	 * @param int                 $page        Target problem-panel page.
	 * @param ProductListQuery    $query       Current product-list query.
	 * @param array<string,mixed> $requestData Request data.
	 */
	private static function buildLinkHealthProblemPageUrl( int $page, ProductListQuery $query, array $requestData ): string {
		$args    = array(
			self::LINK_HEALTH_PROBLEM_PAGE_PARAM => $page,
		);
		$perPage = self::requestPerPage( $requestData, self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM, self::LINK_HEALTH_PROBLEM_PER_PAGE );
		if ( $perPage !== self::LINK_HEALTH_PROBLEM_PER_PAGE ) {
			$args[ self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM ] = $perPage;
		}
		if ( self::requestString( $requestData, 'affilun_link_health_filter' ) === 'problem' ) {
			$args['affilun_link_health_filter'] = 'problem';
		}

		$base = self::buildFilteredUrl( $query );
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $base ) . '#affilun-link-health-problems';
		}

		$separator = str_contains( $base, '?' ) ? '&' : '?';
		return $base . $separator . http_build_query( $args ) . '#affilun-link-health-problems';
	}

	/**
	 * Build a link-health problem panel URL for a changed page size.
	 *
	 * @param int                 $page        Target problem-panel page.
	 * @param int                 $perPage     Target page size.
	 * @param ProductListQuery    $query       Current product-list query.
	 * @param array<string,mixed> $requestData Request data.
	 */
	private static function buildLinkHealthProblemPageUrlWithPerPage(
		int $page,
		int $perPage,
		ProductListQuery $query,
		array $requestData
	): string {
		$args = array(
			self::LINK_HEALTH_PROBLEM_PAGE_PARAM => $page,
		);
		if ( $perPage !== self::LINK_HEALTH_PROBLEM_PER_PAGE ) {
			$args[ self::LINK_HEALTH_PROBLEM_PER_PAGE_PARAM ] = $perPage;
		}
		if ( self::requestString( $requestData, 'affilun_link_health_filter' ) === 'problem' ) {
			$args['affilun_link_health_filter'] = 'problem';
		}

		$base = self::buildFilteredUrl( $query );
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $base ) . '#affilun-link-health-problems';
		}

		$separator = str_contains( $base, '?' ) ? '&' : '?';
		return $base . $separator . http_build_query( $args ) . '#affilun-link-health-problems';
	}

	/**
	 * Translate provider-side sync state for admin display.
	 *
	 * @param string $syncState Provider-side sync state.
	 */
	private static function syncStateLabel( string $syncState ): string {
		return match ( $syncState ) {
			ProductListQuery::SYNC_STATE_FRESH => '最新',
			ProductListQuery::SYNC_STATE_STALE => '要確認',
			ProductListQuery::SYNC_STATE_EXPIRED => '期限切れ',
			ProductListQuery::SYNC_STATE_BROKEN => 'リンク切れ',
			default => $syncState,
		};
	}

	/**
	 * Convert a user-side state token into the stored lifecycle state.
	 *
	 * @param string $state User-side state token.
	 */
	private static function lifecycleFromUserState( string $state ): ?string {
		return match ( $state ) {
			'publish' => ProductListQuery::LIFECYCLE_STATE_ACTIVE,
			'unused' => ProductListQuery::LIFECYCLE_STATE_ARCHIVED,
			'trash' => ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING,
			default => null,
		};
	}

	/**
	 * Convert a stored lifecycle state into a user-side state token.
	 *
	 * @param string|null $lifecycleState Stored lifecycle state.
	 */
	private static function userStateFromLifecycle( ?string $lifecycleState ): ?string {
		return match ( $lifecycleState ) {
			ProductListQuery::LIFECYCLE_STATE_ACTIVE => 'publish',
			ProductListQuery::LIFECYCLE_STATE_ARCHIVED => 'unused',
			ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING => 'trash',
			default => null,
		};
	}

	/**
	 * Whether affilun Cloud article-draft features should be enabled.
	 */
	private function isCloudContracted(): bool {
		if ( $this->settingsRepository === null ) {
			return false;
		}

		try {
			$cloud = $this->settingsRepository->load()->cloud();
		} catch ( \Throwable $unused ) {
			unset( $unused );
			return false;
		}

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

	/**
	 * URL to the affilun Cloud settings tab.
	 */
	private static function cloudSettingsUrl(): string {
		return self::resolveAdminUrl( 'admin.php?page=affilun-settings#affilun-settings-tab-cloud' );
	}

	/**
	 * Products list admin page URL.
	 */
	private static function productsListAdminUrl(): string {
		return self::resolveAdminUrl( 'admin.php?page=' . rawurlencode( self::MENU_SLUG ) );
	}

	/**
	 * Resolve an admin URL via `admin_url()` when available,
	 * falling back to a synthetic absolute URL in pure-unit
	 * harnesses where WordPress is not loaded so `esc_url()` does
	 * not strip the value.
	 *
	 * @param string $path Admin path (e.g. `'admin.php'`).
	 */
	private static function resolveAdminUrl( string $path ): string {
		if ( function_exists( 'admin_url' ) ) {
			return (string) admin_url( $path );
		}

		return 'http://example.test/wp-admin/' . ltrim( $path, '/' );
	}
}
