<?php
/**
 * affilun Cloud tools admin page renderer.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

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

use Affilun\Application\Cloud\CloudCampaignFeedClient;
use Affilun\Application\Cloud\CloudCampaignSuggestion;
use Affilun\Application\Cloud\CloudAiPostScanRunner;
use Affilun\Application\Cloud\CloudApiException;
use Affilun\Application\Cloud\CloudLinkHealthClient;
use Affilun\Application\Cloud\CloudLinkHealthResult;
use Affilun\Application\Cloud\CloudTopAffiliateCandidatesClient;
use Affilun\Application\LinkHealth\LinkHealthStatusClassifier;
use Affilun\Application\LinkHealth\ProductLinkHealthStatusStore;
use Affilun\Application\Settings\CloudSettings;
use Affilun\Application\Settings\PluginSettings;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Infrastructure\Persistence\ProductListQuery;
use Affilun\Infrastructure\Persistence\ProductMetaRepository;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\TopAffiliateCandidateRepository;
use Affilun\Presentation\Admin\AdminPriceFormatter;
use DateTimeImmutable;
use DateTimeZone;
use Throwable;

/**
 * Renders opt-in affilun Cloud monitoring/campaign tools.
 */
final class CloudToolsPageRenderer {

	/**
	 * Product meta key for latest saved-link health status.
	 */
	public const META_KEY_LINK_HEALTH_STATUS = ProductLinkHealthStatusStore::META_KEY_LINK_HEALTH_STATUS;

	/**
	 * Maximum URLs sent through the synchronous Cloud check endpoint.
	 */
	private const DIRECT_LINK_HEALTH_LIMIT = 50;

	/**
	 * Admin-post action for revenue opportunity status updates.
	 */
	public const REVENUE_STATUS_ACTION = 'affilun_update_revenue_opportunity_status';

	/**
	 * Nonce field for revenue opportunity status updates.
	 */
	public const REVENUE_STATUS_NONCE_FIELD = '_affilun_revenue_opportunity_status_nonce';

	/**
	 * Nonce field for link-health job result persistence.
	 */
	private const LINK_HEALTH_JOB_NONCE_FIELD = '_affilun_link_health_job_nonce';

	/**
	 * Nonce field for starting saved-link health scans.
	 */
	private const LINK_HEALTH_SAVED_NONCE_FIELD = '_affilun_link_health_saved_nonce';

	/**
	 * Nonce field for refreshing revenue opportunities.
	 */
	private const REVENUE_REFRESH_NONCE_FIELD = '_affilun_revenue_refresh_nonce';

	/**
	 * Nonce field for refreshing top affiliate candidates.
	 */
	private const TOP_AFFILIATE_REFRESH_NONCE_FIELD = '_affilun_top_affiliate_refresh_nonce';

	public const AI_SCAN_ACTION = 'affilun_cloud_ai_post_scan';

	public const AI_SCAN_NONCE_FIELD = '_affilun_cloud_ai_post_scan_nonce';

	public const AI_SCAN_NONCE_ACTION = 'affilun_cloud_ai_post_scan';

	private const AI_SCAN_FILTER_ALL = 'all';

	private const AI_SCAN_FILTER_INSTALLED = 'installed';

	private const AI_SCAN_FILTER_MISSING = 'missing';

	private const AI_SCAN_FILTER_NO_CANDIDATE = 'no_candidate';

	private const AI_SCAN_DEFAULT_ROWS_PER_PAGE = 50;

	private const TOP_AFFILIATE_DEFAULT_GROUPS_PER_PAGE = 20;

	private const AI_SCAN_PER_PAGE_PARAM = 'affilun_ai_scan_per_page';

	private const TOP_AFFILIATE_PER_PAGE_PARAM = 'affilun_top_affiliate_per_page';

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

	/**
	 * Provider returning post rows that may contain affilun blocks.
	 *
	 * @var callable(): list<array<string,mixed>>
	 */
	private $postRowsProvider;

	/**
	 * Provider returning post rows for the Cloud AI scan list.
	 *
	 * @var callable(): list<array<string,mixed>>
	 */
	private $aiScanPostRowsProvider;

	/**
	 * Campaign providers exposed in the user-facing selector.
	 *
	 * @var array<string,string>
	 */
	private const CAMPAIGN_PROVIDERS = array(
		'amazon'         => 'Amazon',
		'rakuten'        => '楽天市場',
		'yahoo_shopping' => 'Yahoo!ショッピング',
		'mercari'        => 'メルカリ',
	);

	/**
	 * Provider priority used when one normalized candidate has multiple offers.
	 *
	 * @var array<string,int>
	 */
	private const TOP_AFFILIATE_PROVIDER_PRIORITY = array(
		'amazon'         => 0,
		'rakuten'        => 1,
		'yahoo_shopping' => 2,
	);

	/**
	 * @param SettingsRepository                   $settings              Settings repository.
	 * @param CloudLinkHealthClient                $linkHealth            Link health client.
	 * @param CloudCampaignFeedClient              $campaigns             Campaign client.
	 * @param ProductRepository|null               $productRepository     Product repository.
	 * @param ProductMetaRepository|null           $productMetaRepository Product meta repository.
	 * @param callable|null                        $postRowsProvider      Optional post rows provider.
	 * @param CloudTopAffiliateCandidatesClient    $topAffiliateCandidates Top affiliate candidate client.
	 * @param TopAffiliateCandidateRepository|null $topAffiliateCandidateRepository Candidate repository.
	 * @param OptionStore|null                     $optionStore           Option store.
	 * @param callable|null                        $aiScanPostRowsProvider AI scan post provider.
	 */
	public function __construct(
		private SettingsRepository $settings,
		private CloudLinkHealthClient $linkHealth = new CloudLinkHealthClient(),
		private CloudCampaignFeedClient $campaigns = new CloudCampaignFeedClient(),
		private ?ProductRepository $productRepository = null,
		private ?ProductMetaRepository $productMetaRepository = null,
		?callable $postRowsProvider = null,
		private CloudTopAffiliateCandidatesClient $topAffiliateCandidates = new CloudTopAffiliateCandidatesClient(),
		private ?TopAffiliateCandidateRepository $topAffiliateCandidateRepository = null,
		private ?OptionStore $optionStore = null,
		?callable $aiScanPostRowsProvider = null
	) {
		$this->postRowsProvider       = $postRowsProvider ?? self::defaultPostRowsProvider();
		$this->aiScanPostRowsProvider = $aiScanPostRowsProvider ?? self::defaultAiScanPostRowsProvider();
	}

	/**
	 * Render page.
	 *
	 * @param array<string,mixed> $query Query parameters.
	 */
	public function render( array $query = array() ): void {
		$settings = $this->settings->load();
		$cloud    = $settings->cloud();
		echo '<div class="wrap affilun-admin-page affilun-cloud-tools-page">';
		$this->renderHero();
		if ( ! $cloud->isConfigured() ) {
			echo '<div class="notice notice-warning"><p>';
			echo esc_html( 'affilun Cloud が未設定です。設定画面で Site ID と API Key を保存してください。' );
			echo '</p></div>';
		}
		echo '<div class="affilun-products-list-card">';
		$this->renderTopAffiliateCandidatesSection( $cloud, $settings, $query );
		echo '</div>';
		echo '<div class="affilun-products-list-card">';
		$this->renderCampaignSection( $cloud, $settings, $query );
		echo '</div>';
		echo '<div class="affilun-products-list-card">';
		$this->renderAiScanSection( $cloud, $query );
		echo '</div>';
		echo '</div>';
	}

	/**
	 * Render branded Cloud tools hero.
	 */
	private function renderHero(): void {
		$logoUrl = defined( 'AFFILUN_PLUGIN_URL' )
			? AFFILUN_PLUGIN_URL . 'assets/img/brand/logo-landscape-transparent.png'
			: '';

		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( 'Cloudツール' ) . '</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>';
	}

	/**
	 * Render standalone link-health panel for the products page.
	 *
	 * @param array<string,mixed> $query    Query parameters.
	 * @param string              $pageSlug Admin page slug for the GET form.
	 */
	public function renderLinkHealthPanel( array $query = array(), string $pageSlug = 'affilun-products' ): void {
		$cloud = $this->settings->load()->cloud();
		echo '<div id="affilun-link-health-panel" class="affilun-product-link-health">';
		$this->renderLinkHealthSection( $cloud, $query, $pageSlug );
		echo '</div>';
	}

	/**
	 * Render link health section.
	 *
	 * @param CloudSettings       $cloud    Cloud settings.
	 * @param array<string,mixed> $query    Query parameters.
	 * @param string              $pageSlug Admin page slug for the GET form.
	 */
	private function renderLinkHealthSection( CloudSettings $cloud, array $query, string $pageSlug ): void {
		echo '<h2>' . esc_html( 'アフィリエイトリンク監視' ) . '</h2>';
		echo '<p>' . esc_html( '保存済み商品のアフィリエイトURLを affilun Cloud でチェックします。公開済みリンクの機会損失を見つけるための手動スキャンです。' ) . '</p>';
		if ( ! $cloud->isConfigured() ) {
			echo '<div class="notice notice-warning inline"><p>';
			echo esc_html( 'affilun Cloud が未設定です。設定画面で Site ID と API Key を保存してください。' );
			echo '</p></div>';
			return;
		}
		if ( ! self::cloudPlanAllowsLinkHealth( $cloud ) ) {
			echo '<div class="notice notice-info inline"><p>';
			echo esc_html( 'リンクヘルスチェックは有料またはトライアル中の affilun Cloud プランで利用できます。' );
			echo '</p></div>';
			return;
		}
		echo '<form method="get" style="margin-bottom:12px;"><input type="hidden" name="page" value="' . esc_attr( $pageSlug ) . '">';
		echo '<input type="hidden" name="affilun_link_health_saved" value="1">';
		self::renderNonceField( self::linkHealthSavedNonceAction( $pageSlug ), self::LINK_HEALTH_SAVED_NONCE_FIELD );
		echo '<button class="button button-secondary" type="submit">' . esc_html( '手動でリンクをチェック' ) . '</button></form>';

		$jobId = self::queryString( $query, 'affilun_link_health_job' );
		if ( $jobId !== '' ) {
			$this->renderLinkHealthJobPanel( $cloud, $jobId, $pageSlug, self::verifyQueryNonce( $query, self::linkHealthJobNonceAction( $jobId ), self::LINK_HEALTH_JOB_NONCE_FIELD ) );
			return;
		}

		$urls     = array();
		$contexts = array();
		$saved    = self::queryString( $query, 'affilun_link_health_saved' ) === '1';
		if ( $saved ) {
			if ( ! self::verifyQueryNonce( $query, self::linkHealthSavedNonceAction( $pageSlug ), self::LINK_HEALTH_SAVED_NONCE_FIELD ) ) {
				echo '<div class="notice notice-error inline"><p>';
				echo esc_html( 'リンクチェックのリクエストを確認できませんでした。もう一度実行してください。' );
				echo '</p></div>';
				return;
			}
			$contexts = self::mergeLinkHealthContexts(
				$this->savedProductUrlContexts(),
				$this->renderedBlockUrlContexts()
			);
			$urls     = array_keys( $contexts );
		}
		if ( $urls === array() ) {
			if ( $saved ) {
				echo '<p class="description">' . esc_html( 'チェック対象の保存済みリンクはありません。' ) . '</p>';
			}
			return;
		}
		try {
			if ( count( $urls ) > self::DIRECT_LINK_HEALTH_LIMIT ) {
				$job = $this->linkHealth->createJob(
					$cloud,
					$urls,
					time(),
					self::nonce(),
					$contexts
				);
				$this->renderLinkHealthJobCreated( $job, count( $urls ), $pageSlug );
			} else {
				$results = $this->linkHealth->check(
					$cloud,
					$urls,
					time(),
					self::nonce(),
					$contexts
				);
				$this->persistLinkHealthResults( $results, $contexts );
				$this->renderLinkHealthScanSummary( count( $results ), $saved );
				$this->renderLinkHealthResults( $results, $contexts );
			}
		} catch ( Throwable $error ) {
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( self::linkHealthFailureMessage( $error ) );
			echo '</p></div>';
		}
	}

	/**
	 * Whether the cached Cloud plan should expose manual link health checks.
	 *
	 * Empty/unknown plan values are allowed for backward compatibility with
	 * older connected sites; Cloud remains the source of truth at request time.
	 *
	 * @param CloudSettings $cloud Cloud settings.
	 */
	private static function cloudPlanAllowsLinkHealth( CloudSettings $cloud ): bool {
		$plan = strtolower( trim( $cloud->plan() ) );
		if ( $plan === '' ) {
			return true;
		}
		return in_array( $plan, array( 'personal', 'growth', 'agency' ), true );
	}

	/**
	 * Build a user-facing link-health failure message.
	 *
	 * @param Throwable $error Error raised by the Cloud client.
	 */
	private static function linkHealthFailureMessage( Throwable $error ): string {
		if ( $error instanceof CloudApiException ) {
			if ( $error->cloudCode() === 'affilun_cloud_plan_required' ) {
				return 'リンクヘルスチェックは現在の affilun Cloud プランでは利用できません。プランを確認してください。';
			}
			if ( $error->cloudCode() === 'affilun_cloud_cost_quota_exhausted' || $error->cloudCode() === 'affilun_cloud_quota_exhausted' ) {
				return 'affilun Cloud の利用枠に達したため、リンクチェックを実行できませんでした。';
			}
			if ( $error->cloudCode() === 'affilun_cloud_invalid_request' ) {
				return 'チェック対象リンクの形式に問題があるため、affilun Cloud のリンクチェックに失敗しました。';
			}
			if ( $error->cloudCode() === 'affilun_cloud_link_health_jobs_unavailable' ) {
				return 'affilun Cloud のリンク監視ジョブを開始できませんでした。時間をおいて再実行してください。';
			}
			if ( $error->statusCode() > 0 ) {
				return sprintf( 'affilun Cloud のリンクチェックに失敗しました（HTTP %d）。', $error->statusCode() );
			}
		}

		return 'affilun Cloud のリンクチェックに失敗しました。';
	}

	/**
	 * Render a newly-created asynchronous link-health job.
	 *
	 * @param array<string,mixed> $job            Job payload.
	 * @param int                 $submittedCount Number of submitted links.
	 * @param string              $pageSlug       Admin page slug.
	 */
	private function renderLinkHealthJobCreated( array $job, int $submittedCount, string $pageSlug ): void {
		$jobId = is_scalar( $job['id'] ?? null ) ? (string) $job['id'] : '';
		if ( $jobId === '' ) {
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( 'affilun Cloud のリンク監視ジョブを開始できませんでした。' );
			echo '</p></div>';
			return;
		}
		echo '<div class="notice notice-info inline"><p>';
		echo esc_html( sprintf( '保存済みリンク %d 件をチェック対象として送信しました。結果はジョブの完了後に確認できます。', $submittedCount ) );
		echo '</p></div>';
		$this->renderLinkHealthJobSummary( $job );
		$this->renderLinkHealthJobRefreshForm( $jobId, $pageSlug );
	}

	/**
	 * Render an asynchronous link-health job status/results panel.
	 *
	 * @param CloudSettings $cloud    Cloud settings.
	 * @param string        $jobId    Job id.
	 * @param string        $pageSlug Admin page slug.
	 * @param bool          $persist  Whether fetched results may be persisted.
	 */
	private function renderLinkHealthJobPanel( CloudSettings $cloud, string $jobId, string $pageSlug, bool $persist ): void {
		try {
			$response = $this->linkHealth->fetchJobResults(
				$cloud,
				$jobId,
				time(),
				self::nonce()
			);
			$job      = $response['job'];
			$status   = is_scalar( $job['status'] ?? null ) ? (string) $job['status'] : '';
			$this->renderLinkHealthJobSummary( $job );
			if ( in_array( $status, array( 'queued', 'running' ), true ) ) {
				echo '<p class="description">' . esc_html( 'リンク監視ジョブはまだ処理中です。しばらくしてから結果を更新してください。' ) . '</p>';
				$this->renderLinkHealthJobRefreshForm( $jobId, $pageSlug );
				return;
			}
			$results = $response['results'];
			$this->renderLinkHealthJobResultSummary( $response['summary'] );
			if ( $persist ) {
				$this->persistLinkHealthResults( $results, self::contextsFromResults( $results ) );
			}
			$this->renderLinkHealthResults( $results );
		} catch ( Throwable $error ) {
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( self::linkHealthFailureMessage( $error ) );
			echo '</p></div>';
		}
	}

	/**
	 * Render a compact link-health job summary.
	 *
	 * @param array<string,mixed> $job Job payload.
	 */
	private function renderLinkHealthJobSummary( array $job ): void {
		$status = is_scalar( $job['status'] ?? null ) ? (string) $job['status'] : 'unknown';
		$done   = is_numeric( $job['progress_done'] ?? null ) ? (int) $job['progress_done'] : 0;
		$total  = is_numeric( $job['progress_total'] ?? null ) ? (int) $job['progress_total'] : 0;
		echo '<p class="description">';
		echo esc_html( sprintf( 'ジョブ状態: %s / 進捗: %d / %d', $status, $done, $total ) );
		echo '</p>';
		if ( is_array( $job['error'] ?? null ) ) {
			$message = is_scalar( $job['error']['message'] ?? null ) ? (string) $job['error']['message'] : '';
			if ( $message !== '' ) {
				echo '<div class="notice notice-warning inline"><p>';
				echo esc_html( 'Cloud側の処理でエラーが発生しました: ' . $message );
				echo '</p></div>';
			}
		}
	}

	/**
	 * Render job refresh form.
	 *
	 * @param string $jobId    Job id.
	 * @param string $pageSlug Admin page slug.
	 */
	private function renderLinkHealthJobRefreshForm( string $jobId, string $pageSlug ): void {
		echo '<form method="get" style="margin:12px 0;"><input type="hidden" name="page" value="' . esc_attr( $pageSlug ) . '">';
		echo '<input type="hidden" name="affilun_link_health_job" value="' . esc_attr( $jobId ) . '">';
		self::renderNonceField( self::linkHealthJobNonceAction( $jobId ), self::LINK_HEALTH_JOB_NONCE_FIELD );
		echo '<button class="button button-secondary" type="submit">' . esc_html( '結果を更新' ) . '</button></form>';
	}

	/**
	 * Render summary returned by job results endpoint.
	 *
	 * @param array<string,mixed> $summary Summary payload.
	 */
	private function renderLinkHealthJobResultSummary( array $summary ): void {
		$total     = is_numeric( $summary['total_count'] ?? null ) ? (int) $summary['total_count'] : 0;
		$unchecked = is_numeric( $summary['unchecked_count'] ?? null ) ? (int) $summary['unchecked_count'] : 0;
		if ( $unchecked > 0 ) {
			echo '<div class="notice notice-warning inline"><p>';
			echo esc_html( sprintf( '%d / %d 件はまだ未チェックです。', $unchecked, $total ) );
			echo '</p></div>';
		}
	}

	/**
	 * Render link health scan summary.
	 *
	 * @param int  $count Number of URLs submitted.
	 * @param bool $saved Whether this is a saved-product scan.
	 */
	private function renderLinkHealthScanSummary( int $count, bool $saved ): void {
		$label = $saved ? '保存済みリンク' : '任意URL';
		echo '<p class="description">'
			. esc_html( sprintf( '%s %d 件をチェックしました。', $label, $count ) )
			. '</p>';
	}

	/**
	 * Render link health result table.
	 *
	 * @param list<CloudLinkHealthResult>       $results  Results.
	 * @param array<string,array<string,mixed>> $contexts Saved product context by URL.
	 */
	private function renderLinkHealthResults( array $results, array $contexts = array() ): void {
		if ( $results === array() ) {
			echo '<p>' . esc_html( 'チェック対象URLはありません。' ) . '</p>';
			return;
		}
		$this->renderLinkHealthResultSummary( $results );
		echo '<table class="wp-list-table widefat striped"><thead><tr>';
		foreach ( array( 'Product', 'URL', 'Status', 'HTTP', 'Message', 'Checked' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead><tbody>';
		foreach ( $results as $result ) {
			$context = self::contextForResult( $result, $contexts );
			$label   = is_scalar( $context['label'] ?? null ) ? (string) $context['label'] : '任意URL';
			echo '<tr>';
			echo '<td>' . esc_html( $label ) . '</td>';
			echo '<td>' . esc_html( $result->url() ) . '</td>';
			echo '<td>' . esc_html( $result->status() ) . '</td>';
			echo '<td>' . esc_html( (string) $result->httpStatus() ) . '</td>';
			echo '<td>' . esc_html( $result->message() ) . '</td>';
			echo '<td>' . esc_html( $result->checkedAtIso() ) . '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
	}

	/**
	 * Render a compact result summary before the result table.
	 *
	 * @param list<CloudLinkHealthResult> $results Results.
	 */
	private function renderLinkHealthResultSummary( array $results ): void {
		$counts = array(
			'ok'      => 0,
			'warning' => 0,
			'broken'  => 0,
			'unknown' => 0,
		);
		foreach ( $results as $result ) {
			++$counts[ self::linkHealthStatusClass( $result ) ];
		}

		echo '<p class="description">';
		echo '<span class="affilun-link-health-status affilun-link-health-status--ok">'
			. esc_html( sprintf( '正常 %d', $counts['ok'] ) ) . '</span> ';
		echo '<span class="affilun-link-health-status affilun-link-health-status--warning">'
			. esc_html( sprintf( '注意 %d', $counts['warning'] ) ) . '</span> ';
		echo '<span class="affilun-link-health-status affilun-link-health-status--broken">'
			. esc_html( sprintf( '問題 %d', $counts['broken'] ) ) . '</span> ';
		if ( $counts['unknown'] > 0 ) {
			echo '<span class="affilun-link-health-status affilun-link-health-status--unknown">'
				. esc_html( sprintf( '不明 %d', $counts['unknown'] ) ) . '</span>';
		}
		echo '</p>';
		if ( $counts['broken'] > 0 ) {
			echo '<div class="notice notice-warning inline"><p>'
				. esc_html( sprintf( '問題のあるリンクが %d 件あります。商品一覧のリンク監視カラムで対象を確認してください。', $counts['broken'] ) )
				. '</p></div>';
		} elseif ( $counts['warning'] > 0 ) {
			echo '<div class="notice notice-info inline"><p>'
				. esc_html( sprintf( '確認が必要なリンクが %d 件あります。リダイレクトやタイムアウトを確認してください。', $counts['warning'] ) )
				. '</p></div>';
		}
	}

	/**
	 * Resolve CSS class suffix for link-health status.
	 *
	 * @param CloudLinkHealthResult $result Result.
	 */
	private static function linkHealthStatusClass( CloudLinkHealthResult $result ): string {
		return LinkHealthStatusClassifier::classify( $result->status(), $result->httpStatus() );
	}

	/**
	 * Persist latest link-health results to product meta.
	 *
	 * @param list<CloudLinkHealthResult>       $results  Results.
	 * @param array<string,array<string,mixed>> $contexts Saved product context by URL.
	 */
	private function persistLinkHealthResults( array $results, array $contexts ): void {
		if ( $this->productMetaRepository === null || $contexts === array() ) {
			return;
		}

		$store = new ProductLinkHealthStatusStore( $this->productMetaRepository );
		$store->persistResults(
			$results,
			$contexts,
			new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) )
		);
	}

	/**
	 * Resolve display/persistence context for one result.
	 *
	 * @param CloudLinkHealthResult             $result   Result.
	 * @param array<string,array<string,mixed>> $contexts Contexts keyed by URL.
	 *
	 * @return array<string,mixed>
	 */
	private static function contextForResult( CloudLinkHealthResult $result, array $contexts ): array {
		$context = $contexts[ $result->url() ] ?? array();
		if ( $context !== array() ) {
			return $context;
		}

		return $result->context();
	}

	/**
	 * Build URL-keyed contexts from Cloud result rows.
	 *
	 * @param list<CloudLinkHealthResult> $results Results.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private static function contextsFromResults( array $results ): array {
		$contexts = array();
		foreach ( $results as $result ) {
			$context = $result->context();
			if ( $context !== array() ) {
				$contexts[ $result->url() ] = $context;
			}
		}

		return $contexts;
	}

	/**
	 * Collect affiliate/detail URLs from saved products.
	 *
	 * @return array<string,array{product_id:int|null,label:string}> Product context keyed by URL.
	 */
	private function savedProductUrlContexts(): array {
		if ( $this->productRepository === null ) {
			return array();
		}

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

		$contexts = array();
		foreach ( $result->items() as $record ) {
			$url = $this->urlForProductRecord( $record );
			if ( $url !== '' && ! array_key_exists( $url, $contexts ) ) {
				$contexts[ $url ] = array(
					'product_id'       => $record->id(),
					'provider'         => $record->provider(),
					'provider_item_id' => $record->providerItemId(),
					'label'            => $this->labelForProductRecord( $record ),
				);
			}
		}

		return $contexts;
	}

	/**
	 * Collect custom button URLs from rendered affilun blocks in posts.
	 *
	 * @return array<string,array{product_id:null,label:string}> Context keyed by URL.
	 */
	private function renderedBlockUrlContexts(): array {
		$rows     = ( $this->postRowsProvider )();
		$contexts = array();
		foreach ( $rows as $row ) {
			$postId  = self::positiveInt( $row['ID'] ?? $row['id'] ?? null );
			$title   = is_scalar( $row['post_title'] ?? null ) ? (string) $row['post_title'] : '(タイトルなし)';
			$content = is_string( $row['post_content'] ?? null ) ? $row['post_content'] : '';
			if ( $postId === null || $content === '' ) {
				continue;
			}
			$label = sprintf( 'Post #%d / %s', $postId, $title );
			foreach ( self::customButtonUrlsFromContent( $content ) as $url ) {
				if ( ! array_key_exists( $url, $contexts ) ) {
					$contexts[ $url ] = array(
						'product_id' => null,
						'label'      => $label,
					);
				}
			}
			foreach ( $this->productUrlContextsFromContent( $content, $label ) as $url => $context ) {
				if ( ! array_key_exists( $url, $contexts ) ) {
					$contexts[ $url ] = $context;
				}
			}
		}

		return $contexts;
	}

	/**
	 * Merge primary saved-product contexts with post-level contexts.
	 *
	 * @param array<string,array<string,mixed>> $primary   Primary contexts.
	 * @param array<string,array<string,mixed>> $secondary Secondary contexts.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private static function mergeLinkHealthContexts( array $primary, array $secondary ): array {
		$merged = $primary;
		foreach ( $secondary as $url => $context ) {
			if ( ! array_key_exists( $url, $merged ) ) {
				$merged[ $url ] = $context;
				continue;
			}

			$primaryLabel   = is_scalar( $merged[ $url ]['label'] ?? null ) ? (string) $merged[ $url ]['label'] : '';
			$secondaryLabel = is_scalar( $context['label'] ?? null ) ? (string) $context['label'] : '';
			if ( $secondaryLabel !== '' && ! str_contains( $primaryLabel, $secondaryLabel ) ) {
				$merged[ $url ]['label'] = $primaryLabel === ''
					? $secondaryLabel
					: $primaryLabel . ' / ' . $secondaryLabel;
			}
			if ( ! is_int( $merged[ $url ]['product_id'] ?? null ) && is_int( $context['product_id'] ?? null ) ) {
				$merged[ $url ]['product_id'] = $context['product_id'];
			}
			foreach ( array( 'provider', 'provider_item_id' ) as $key ) {
				if (
					! is_scalar( $merged[ $url ][ $key ] ?? null )
					&& is_scalar( $context[ $key ] ?? null )
				) {
					$merged[ $url ][ $key ] = (string) $context[ $key ];
				}
			}
		}

		return $merged;
	}

	/**
	 * Collect product URLs from affilun block product id attributes.
	 *
	 * @param string $content   Post content.
	 * @param string $postLabel Post label.
	 *
	 * @return array<string,array{product_id:int,label:string}> Context keyed by URL.
	 */
	private function productUrlContextsFromContent( string $content, string $postLabel ): array {
		if ( $this->productRepository === null ) {
			return array();
		}

		$contexts = array();
		foreach ( self::productIdsFromContent( $content ) as $productId ) {
			try {
				$record = $this->productRepository->findById( $productId );
			} catch ( Throwable $unused ) {
				unset( $unused );
				continue;
			}
			if ( $record === null ) {
				continue;
			}
			$url = $this->urlForProductRecord( $record );
			if ( $url === '' || array_key_exists( $url, $contexts ) ) {
				continue;
			}
			$contexts[ $url ] = array(
				'product_id'       => $productId,
				'provider'         => $record->provider(),
				'provider_item_id' => $record->providerItemId(),
				'label'            => sprintf( '%s / %s', $postLabel, $this->labelForProductRecord( $record ) ),
			);
		}

		return $contexts;
	}

	/**
	 * Extract product ids from affilun block content.
	 *
	 * @param string $content Post content.
	 *
	 * @return list<int>
	 */
	private static function productIdsFromContent( string $content ): array {
		$attrsList = self::blockAttributesFromContent( $content );
		$ids       = array();
		foreach ( $attrsList as $attrs ) {
			$id = self::positiveInt( $attrs['productId'] ?? null );
			if ( $id !== null && ! in_array( $id, $ids, true ) ) {
				$ids[] = $id;
			}
			$productIds = is_array( $attrs['productIds'] ?? null ) ? $attrs['productIds'] : array();
			foreach ( $productIds as $rawId ) {
				$id = self::positiveInt( $rawId );
				if ( $id !== null && ! in_array( $id, $ids, true ) ) {
					$ids[] = $id;
				}
			}
		}

		return $ids;
	}

	/**
	 * Extract custom button URLs from affilun block content.
	 *
	 * @param string $content Post content.
	 *
	 * @return list<string>
	 */
	private static function customButtonUrlsFromContent( string $content ): array {
		$attrsList = self::blockAttributesFromContent( $content );
		$urls      = array();
		foreach ( $attrsList as $attrs ) {
			$buttons = is_array( $attrs['customButtons'] ?? null ) ? $attrs['customButtons'] : array();
			foreach ( $buttons as $button ) {
				if ( ! is_array( $button ) || ! is_scalar( $button['url'] ?? null ) ) {
					continue;
				}
				$url = trim( (string) $button['url'] );
				if ( preg_match( '#\Ahttps?://#i', $url ) === 1 && ! in_array( $url, $urls, true ) ) {
					$urls[] = $url;
				}
			}
		}

		return $urls;
	}

	/**
	 * Extract affilun block attributes from serialized content.
	 *
	 * @param string $content Post content.
	 *
	 * @return list<array<string,mixed>>
	 */
	private static function blockAttributesFromContent( string $content ): array {
		if (
			preg_match_all(
				'/<!--\s+wp:affilun\/product-card\s+(\{.*?\})\s+\/?-->/s',
				$content,
				$matches
			) === false
		) {
			return array();
		}

		$attrs = array();
		foreach ( $matches[1] as $json ) {
			$decoded = json_decode( (string) $json, true );
			if ( is_array( $decoded ) ) {
				$attrs[] = $decoded;
			}
		}

		return $attrs;
	}

	/**
	 * Positive integer helper.
	 *
	 * @param mixed $value Candidate value.
	 */
	private static function positiveInt( mixed $value ): ?int {
		if ( is_int( $value ) ) {
			return $value > 0 ? $value : null;
		}
		if ( is_string( $value ) && ctype_digit( $value ) ) {
			$intValue = (int) $value;
			return $intValue > 0 ? $intValue : null;
		}

		return null;
	}

	/**
	 * Pick the URL to check for one saved product.
	 *
	 * @param ProductRecord $record Product record.
	 */
	private function urlForProductRecord( ProductRecord $record ): string {
		$affiliateUrl = $record->affiliateUrl();
		if ( is_string( $affiliateUrl ) && $affiliateUrl !== '' ) {
			return $affiliateUrl;
		}

		return $record->detailUrl();
	}

	/**
	 * Build a compact label for one saved product.
	 *
	 * @param ProductRecord $record Product record.
	 */
	private function labelForProductRecord( ProductRecord $record ): string {
		$id = $record->id();
		return sprintf(
			'#%s %s / %s',
			$id === null ? '-' : (string) $id,
			$record->provider(),
			$record->title()
		);
	}

	/**
	 * Build default post rows provider.
	 *
	 * @return callable(): list<array<string,mixed>>
	 */
	private static function defaultPostRowsProvider(): callable {
		return static function (): array {
			global $wpdb;
			if ( ! is_object( $wpdb ) || ! isset( $wpdb->posts ) || ! method_exists( $wpdb, 'get_results' ) ) {
				return array();
			}

			$sql = "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_status NOT IN ('trash', 'auto-draft') AND post_content LIKE '%wp:affilun/product%' ORDER BY post_modified_gmt DESC LIMIT 200";
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Read-only bounded scan of local posts for AFFILUN block comments; table name is from $wpdb and no user input is interpolated.
			$rows = $wpdb->get_results( $sql, ARRAY_A );
			return is_array( $rows ) ? $rows : array();
		};
	}

	/**
	 * Render Cloud top-selling affiliate candidates.
	 *
	 * @param CloudSettings       $cloud    Cloud settings.
	 * @param PluginSettings      $settings Plugin settings.
	 * @param array<string,mixed> $query    Query parameters.
	 */
	private function renderTopAffiliateCandidatesSection( CloudSettings $cloud, PluginSettings $settings, array $query ): void {
		echo '<h2>' . esc_html( '売れ筋アフィリエイト候補' ) . '</h2>';
		echo '<p>' . esc_html( '有効化しているアフィリエイトプロバイダーに対応した売れ筋候補を affilun Cloud から取得し、候補専用テーブルに保存して表示します。この取得はCloud利用クォータを消費しません。' ) . '</p>';

		if ( ! $cloud->isConfigured() ) {
			echo '<p>' . esc_html( '候補を取得するには、affilun Cloud に接続してください。' ) . '</p>';
			return;
		}
		if ( ! self::cloudPlanAllowsLinkHealth( $cloud ) ) {
			echo '<div class="notice notice-info inline"><p>';
			echo esc_html( '売れ筋アフィリエイト候補は有料またはトライアル中の affilun Cloud プランで利用できます。' );
			echo '</p></div>';
			return;
		}
		if ( $this->topAffiliateCandidateRepository === null ) {
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( '候補保存テーブルを利用できません。プラグインのデータベースマイグレーションを確認してください。' );
			echo '</p></div>';
			return;
		}

		$providers = $this->enabledTopAffiliateProviders( $settings );
		if ( $providers === array() ) {
			echo '<p>' . esc_html( '候補取得に使うアフィリエイトプロバイダーを設定画面で有効化してください。' ) . '</p>';
			return;
		}

		echo '<form method="get" style="margin-bottom:12px;"><input type="hidden" name="page" value="affilun-cloud-tools">';
		echo '<input type="hidden" name="affilun_top_affiliate_refresh" value="1">';
		self::renderNonceField( self::topAffiliateRefreshNonceAction(), self::TOP_AFFILIATE_REFRESH_NONCE_FIELD );
		echo '<button class="button button-secondary" type="submit">' . esc_html( '売れ筋候補を更新' ) . '</button>';
		echo ' <span class="description">' . esc_html( '対象: ' . implode( ', ', array_map( static fn ( string $provider ): string => self::CAMPAIGN_PROVIDERS[ $provider ] ?? $provider, $providers ) ) ) . '</span>';
		echo '</form>';

		if ( self::queryString( $query, 'affilun_top_affiliate_refresh' ) === '1' ) {
			if ( ! self::verifyQueryNonce( $query, self::topAffiliateRefreshNonceAction(), self::TOP_AFFILIATE_REFRESH_NONCE_FIELD ) ) {
				echo '<div class="notice notice-error inline"><p>';
				echo esc_html( '売れ筋候補の更新リクエストを確認できませんでした。もう一度実行してください。' );
				echo '</p></div>';
			} else {
				try {
					$rows     = $this->topAffiliateCandidates->fetch( $cloud, $providers, 50, time(), self::nonce() );
					$inserted = $this->topAffiliateCandidateRepository->replaceAll( $rows );
					echo '<div class="notice notice-success inline"><p>';
					echo esc_html( sprintf( '%d件の売れ筋候補を更新しました。', $inserted ) );
					echo '</p></div>';
				} catch ( Throwable $unused ) {
					unset( $unused );
					echo '<div class="notice notice-error inline"><p>';
					echo esc_html( 'affilun Cloud の売れ筋候補取得に失敗しました。時間をおいて再実行してください。' );
					echo '</p></div>';
				}
			}
		}

		$currentPage = max( 1, self::queryInt( $query, 'affilun_top_affiliate_paged', 1 ) );
		$perPage     = self::queryPerPage( $query, self::TOP_AFFILIATE_PER_PAGE_PARAM, self::TOP_AFFILIATE_DEFAULT_GROUPS_PER_PAGE );
		try {
			$records = $this->topAffiliateCandidateRecords( $providers );
			$this->renderTopAffiliateCandidateTable( $records, $currentPage, $perPage );
		} catch ( Throwable $unused ) {
			unset( $unused );
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( '保存済み候補の読み込みに失敗しました。' );
			echo '</p></div>';
		}
	}

	/**
	 * Load locally cached top affiliate candidates across repository pages.
	 *
	 * @param list<string> $providers Enabled providers.
	 *
	 * @return list<ProductRecord>
	 */
	private function topAffiliateCandidateRecords( array $providers ): array {
		if ( $this->topAffiliateCandidateRepository === null ) {
			return array();
		}

		$records = array();
		$page    = 1;
		do {
			$result = $this->topAffiliateCandidateRepository->findPaginated(
				new ProductListQuery(
					$page,
					50,
					null,
					null,
					null,
					null,
					ProductListQuery::LIFECYCLE_STATE_ACTIVE,
					ProductListQuery::SORT_KEY_LAST_SYNCED_AT,
					ProductListQuery::SORT_ORDER_DESC
				)
			);
			foreach ( $result->items() as $record ) {
				if ( in_array( $record->provider(), $providers, true ) ) {
					$records[] = $record;
				}
			}
			$itemCount   = count( $result->items() );
			$recordCount = count( $records );
			++$page;
		} while ( $itemCount === 50 && $recordCount < 500 );

		return $records;
	}

	/**
	 * @param PluginSettings $settings Plugin settings.
	 *
	 * @return list<string>
	 */
	private function enabledTopAffiliateProviders( PluginSettings $settings ): array {
		$providers = array();
		foreach ( self::CAMPAIGN_PROVIDERS as $key => $label ) {
			unset( $label );
			$provider = $settings->provider( $key );
			if ( $provider !== null && $provider->enabled() && $provider->isConfigured() ) {
				$providers[] = $key;
			}
		}

		return $providers;
	}

	/**
	 * @param list<ProductRecord> $records     Candidate records.
	 * @param int                 $currentPage Current page.
	 * @param int                 $perPage     Current page size.
	 */
	private function renderTopAffiliateCandidateTable( array $records, int $currentPage, int $perPage ): void {
		$groups      = self::topAffiliateCandidateGroups( $records );
		$totalGroups = count( $groups );
		if ( $groups === array() ) {
			echo '<p>' . esc_html( '保存済みの売れ筋候補はありません。' ) . '</p>';
			return;
		}
		$totalPages  = max( 1, (int) ceil( $totalGroups / $perPage ) );
		$currentPage = min( max( 1, $currentPage ), $totalPages );
		$pageGroups  = array_slice(
			$groups,
			( $currentPage - 1 ) * $perPage,
			$perPage
		);

		echo '<div id="affilun-top-affiliate-candidates" class="affilun-top-affiliate-candidates">';
		echo '<div class="tablenav top">';
		$this->renderTopAffiliatePagination( $currentPage, $totalPages, $totalGroups, $perPage );
		echo '<br class="clear"></div>';
		echo '<table class="wp-list-table widefat striped affilun-products-table"><thead><tr>';
		foreach ( array( 'プロバイダー', '商品名', '価格', '操作' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead><tbody>';
		foreach ( $pageGroups as $group ) {
			$record      = self::preferredTopAffiliateCandidate( $group );
			$image       = $record->imagePrimaryUrl();
			$productUrl  = $record->affiliateUrl() ?? $record->detailUrl();
			$title       = $record->title();
			$displayText = self::truncateText( $title, 72 );
			$payload     = wp_json_encode( self::topAffiliateCandidateSavePayload( $record, $group ) );
			if ( ! is_string( $payload ) ) {
				$payload = '{}';
			}

			echo '<tr>';
			echo '<td>' . esc_html( self::topAffiliateCandidateProviderLabel( $group ) ) . '</td>';
			echo '<td>';
			echo '<div style="display:flex;align-items:center;gap:8px;min-width:0;">';
			if ( $image !== null && $image !== '' ) {
				echo '<img src="' . esc_url( $image ) . '"'
					. ' alt="" loading="lazy" width="48" height="48"'
					. ' style="width:48px;height:48px;object-fit:contain;'
					. 'flex:0 0 auto;border:1px solid #e5e5e5;background:#fff;">';
			} else {
				echo '<span aria-hidden="true" style="display:inline-block;width:48px;height:48px;'
					. 'flex:0 0 auto;border:1px solid #e5e5e5;background:#f6f7f7;"></span>';
			}
			echo '<a href="' . esc_url( $productUrl ) . '" target="_blank" rel="noopener noreferrer" title="' . esc_attr( $title ) . '">'
				. esc_html( $displayText ) . '</a>';
			echo '</div>';
			echo '</td>';
			echo '<td>' . esc_html( AdminPriceFormatter::formatProductRecord( $record ) ) . '</td>';
			echo '<td>';
			echo '<button type="button" class="button button-primary affilun-top-affiliate-add" data-affilun-product-payload="' . esc_attr( $payload ) . '">'
				. esc_html( '追加' ) . '</button>';
			echo '<div class="affilun-top-affiliate-add-status" aria-live="polite" style="font-size:12px;margin-top:4px;"></div>';
			echo '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
		echo '<div class="tablenav bottom">';
		$this->renderTopAffiliatePagination( $currentPage, $totalPages, $totalGroups, $perPage );
		echo '<br class="clear"></div>';
		echo '</div>';
		$this->renderTopAffiliateCandidateScript();
	}

	/**
	 * Render top affiliate pagination.
	 *
	 * @param int $currentPage Current page.
	 * @param int $totalPages  Total pages.
	 * @param int $totalGroups Total candidate groups.
	 * @param int $perPage     Current page size.
	 */
	private function renderTopAffiliatePagination( int $currentPage, int $totalPages, int $totalGroups, int $perPage ): void {
		echo '<div class="tablenav-pages">';
		echo '<span class="displaying-num">' . esc_html( sprintf( '%d件', $totalGroups ) ) . '</span> ';
		$this->renderPerPageLinks(
			self::TOP_AFFILIATE_PER_PAGE_PARAM,
			$perPage,
			static fn ( int $option ): string => self::topAffiliatePageUrl( 1, $option )
		);
		if ( $currentPage > 1 ) {
			echo '<a class="button" href="' . esc_url( self::topAffiliatePageUrl( $currentPage - 1, $perPage ) ) . '">' . esc_html( '前へ' ) . '</a> ';
		}
		echo '<span class="paging-input">' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . '</span> ';
		if ( $currentPage < $totalPages ) {
			echo '<a class="button" href="' . esc_url( self::topAffiliatePageUrl( $currentPage + 1, $perPage ) ) . '">' . esc_html( '次へ' ) . '</a>';
		}
		echo '</div>';
	}

	/**
	 * @param list<ProductRecord> $records Candidate records.
	 *
	 * @return list<list<ProductRecord>>
	 */
	private static function topAffiliateCandidateGroups( array $records ): array {
		$groups = array();
		foreach ( $records as $record ) {
			$groups[ $record->canonicalHash() ][] = $record;
		}

		$result = array();
		foreach ( $groups as $group ) {
			if ( is_array( $group ) && $group !== array() ) {
				$result[] = self::sortTopAffiliateCandidateGroup( $group );
			}
		}

		return $result;
	}

	/**
	 * @param list<ProductRecord> $group Candidate group.
	 *
	 * @return list<ProductRecord>
	 */
	private static function sortTopAffiliateCandidateGroup( array $group ): array {
		usort(
			$group,
			static function ( ProductRecord $left, ProductRecord $right ): int {
				$rank = self::topAffiliateProviderRank( $left->provider() )
					<=> self::topAffiliateProviderRank( $right->provider() );
				if ( $rank !== 0 ) {
					return $rank;
				}

				return strcmp( $left->providerItemId(), $right->providerItemId() );
			}
		);

		return $group;
	}

	/**
	 * @param list<ProductRecord> $group Candidate group.
	 */
	private static function preferredTopAffiliateCandidate( array $group ): ProductRecord {
		return self::sortTopAffiliateCandidateGroup( $group )[0];
	}

	/**
	 * Resolve display priority for a provider token.
	 *
	 * @param string $provider Provider token.
	 */
	private static function topAffiliateProviderRank( string $provider ): int {
		return self::TOP_AFFILIATE_PROVIDER_PRIORITY[ $provider ] ?? 1000;
	}

	/**
	 * @param list<ProductRecord> $group Candidate group.
	 */
	private static function topAffiliateCandidateProviderLabel( array $group ): string {
		$labels = array();
		foreach ( self::uniqueTopAffiliateProviderRecords( $group ) as $record ) {
			$labels[] = self::CAMPAIGN_PROVIDERS[ $record->provider() ] ?? $record->provider();
		}

		return implode( ' / ', $labels );
	}

	/**
	 * Build the same REST payload used by the product-search add flow.
	 *
	 * @param ProductRecord       $record Candidate record.
	 * @param list<ProductRecord> $group  Normalized candidate group.
	 *
	 * @return array<string,mixed>
	 */
	private static function topAffiliateCandidateSavePayload( ProductRecord $record, array $group = array() ): array {
		$snapshot = array(
			'title'      => $record->title(),
			'detail_url' => $record->detailUrl(),
		);
		if ( $record->brand() !== null && $record->brand() !== '' ) {
			$snapshot['brand'] = $record->brand();
		}
		if ( $record->affiliateUrl() !== null && $record->affiliateUrl() !== '' ) {
			$snapshot['affiliate_url'] = $record->affiliateUrl();
		}
		if ( $record->imagePrimaryUrl() !== null && $record->imagePrimaryUrl() !== '' ) {
			$snapshot['image_primary_url'] = $record->imagePrimaryUrl();
		}
		if ( $record->priceAmount() !== null && $record->priceCurrency() !== null ) {
			$price = array(
				'amount'   => $record->priceAmount(),
				'currency' => $record->priceCurrency(),
			);
			if ( $record->priceLabel() !== null && $record->priceLabel() !== '' ) {
				$price['label'] = $record->priceLabel();
			}
			$snapshot['price'] = $price;
		}

		$payload      = array(
			'provider'         => $record->provider(),
			'provider_item_id' => $record->providerItemId(),
			'snapshot'         => $snapshot,
		);
		$providerUrls = self::topAffiliateCandidateProviderUrls( $record, $group );
		if ( $providerUrls !== array() ) {
			$payload['provider_urls'] = $providerUrls;
		}

		return $payload;
	}

	/**
	 * @param ProductRecord       $primary Primary product saved as snapshot.
	 * @param list<ProductRecord> $group   Normalized candidate group.
	 *
	 * @return list<array<string,string>>
	 */
	private static function topAffiliateCandidateProviderUrls( ProductRecord $primary, array $group ): array {
		$records = self::uniqueTopAffiliateProviderRecords( $group === array() ? array( $primary ) : $group );
		$urls    = array();
		foreach ( $records as $record ) {
			if (
				$record->provider() === $primary->provider()
				&& $record->providerItemId() === $primary->providerItemId()
			) {
				continue;
			}
			$url = $record->affiliateUrl() ?? $record->detailUrl();
			if ( $url === '' ) {
				continue;
			}
			$urls[] = array(
				'provider_type'    => $record->provider(),
				'provider_item_id' => $record->providerItemId(),
				'url'              => $url,
				'source'           => 'cloud_top_affiliate_candidate',
			);
		}

		return $urls;
	}

	/**
	 * @param list<ProductRecord> $group Candidate group.
	 *
	 * @return list<ProductRecord>
	 */
	private static function uniqueTopAffiliateProviderRecords( array $group ): array {
		$records = array();
		$seen    = array();
		foreach ( self::sortTopAffiliateCandidateGroup( $group ) as $record ) {
			if ( array_key_exists( $record->provider(), $seen ) ) {
				continue;
			}
			$seen[ $record->provider() ] = true;
			$records[]                   = $record;
		}

		return $records;
	}

	/**
	 * Truncate visible table text without changing the link target/title.
	 *
	 * @param string $text  Text to truncate.
	 * @param int    $limit Maximum visible characters.
	 */
	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;
	}

	/**
	 * Enqueue the add-candidate interaction script.
	 */
	private function renderTopAffiliateCandidateScript(): void {
		$strings   = array(
			'adding'  => '追加中…',
			'add'     => '追加',
			'added'   => '追加しました (#%d)',
			'failed'  => '追加に失敗しました。',
			'no_api'  => 'wp.apiFetch が読み込まれていないため、商品を追加できません。',
			'invalid' => '候補データが不正なため追加できません。',
		);
		$stringsJs = wp_json_encode( $strings );
		if ( ! is_string( $stringsJs ) ) {
			$stringsJs = '{}';
		}

		$script  = '(function(){'
			. 'var STRINGS=' . $stringsJs . ';'
			. <<<'JS'
function format(tpl,n){return tpl.replace('%d',String(n));}
function extractSavedId(response){
  if(response&&response.data){
    var d=response.data;
    if(typeof d.id==='number'&&d.id>0)return d.id;
    if(d.product&&typeof d.product.id==='number'&&d.product.id>0)return d.product.id;
  }
  if(response&&typeof response.id==='number'&&response.id>0)return response.id;
  return null;
}
function statusFor(btn){
  var parent=btn.parentNode;
  if(!parent)return null;
  return parent.querySelector('.affilun-top-affiliate-add-status');
}
function setStatus(btn,text,isError){
  var el=statusFor(btn);
  if(!el)return;
  el.textContent=text||'';
  el.style.color=isError?'#b32d2e':'';
}
function payloadFor(btn){
  try {
    var payload=JSON.parse(btn.getAttribute('data-affilun-product-payload')||'{}');
    if(!payload||typeof payload!=='object'||!payload.provider||!payload.provider_item_id||!payload.snapshot||!payload.snapshot.title||!payload.snapshot.detail_url)return null;
    return payload;
  } catch(e) {
    return null;
  }
}
function onAddClick(btn){
  if(!window.wp||typeof window.wp.apiFetch!=='function'){
    setStatus(btn,STRINGS.no_api,true);
    return;
  }
  var payload=payloadFor(btn);
  if(payload===null){
    setStatus(btn,STRINGS.invalid,true);
    return;
  }
  btn.disabled=true;
  btn.textContent=STRINGS.adding;
  setStatus(btn,'',false);
  window.wp.apiFetch({path:'/affilun/v1/products',method:'POST',data:payload}).then(function(response){
    var id=extractSavedId(response);
    if(id===null){
      btn.disabled=false;
      btn.textContent=STRINGS.add;
      setStatus(btn,STRINGS.failed,true);
      return;
    }
    btn.textContent=format(STRINGS.added,id);
  }).catch(function(err){
    btn.disabled=false;
    btn.textContent=STRINGS.add;
    var msg=STRINGS.failed;
    if(err&&typeof err.message==='string'&&err.message!=='')msg=err.message;
    else if(err&&err.error&&typeof err.error.message==='string')msg=err.error.message;
    setStatus(btn,msg,true);
  });
}
function init(){
  var buttons=document.querySelectorAll('.affilun-top-affiliate-add');
  for(var i=0;i<buttons.length;i++){
    buttons[i].addEventListener('click',(function(btn){return function(){onAddClick(btn);};})(buttons[i]));
  }
}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',init);}else{init();}
JS;
		$script .= '})();';
		self::enqueueInlineScript( 'affilun-cloud-tools-top-affiliate', $script );
	}

	/**
	 * Attach inline admin JavaScript through WordPress enqueue APIs.
	 *
	 * @param string $handle Script handle.
	 * @param string $script JavaScript source.
	 */
	private static function enqueueInlineScript( string $handle, string $script ): void {
		if (
			! function_exists( 'wp_register_script' )
			|| ! function_exists( 'wp_enqueue_script' )
			|| ! function_exists( 'wp_add_inline_script' )
		) {
			return;
		}

		wp_register_script(
			$handle,
			false,
			array( 'wp-api-fetch' ),
			defined( 'AFFILUN_VERSION' ) ? AFFILUN_VERSION : '0.7.3',
			true
		);
		wp_enqueue_script( $handle );
		wp_add_inline_script( $handle, $script );
	}

	/**
	 * Render Cloud AI post scan section.
	 *
	 * @param CloudSettings       $cloud Cloud settings.
	 * @param array<string,mixed> $query Query parameters.
	 */
	private function renderAiScanSection( CloudSettings $cloud, array $query ): void {
		echo '<h2>' . esc_html( 'AIスキャン機能' ) . '</h2>';
		echo '<p>' . esc_html( 'アフィリエイト未設置の記事を affilun Cloud で解析し、有効化済みプロバイダーの商品検索結果から affilun 商品カードを記事末尾へ追加します。' ) . '</p>';
		if ( ! $cloud->isConfigured() ) {
			echo '<div class="notice notice-warning inline"><p>';
			echo esc_html( 'affilun Cloud が未設定です。設定画面で Site ID と API Key を保存してください。' );
			echo '</p></div>';
			return;
		}

		$this->renderAiScanNotice( $query );
		$this->renderAiScanJobStatus( $query );

		$filter      = self::cleanAiScanFilter( self::queryString( $query, 'affilun_ai_scan_filter' ) );
		$currentPage = max( 1, self::queryInt( $query, 'affilun_ai_scan_paged', 1 ) );
		$perPage     = self::queryPerPage( $query, self::AI_SCAN_PER_PAGE_PARAM, self::AI_SCAN_DEFAULT_ROWS_PER_PAGE );
		$allRows     = $this->filteredAiScanRows( $filter );
		$totalRows   = count( $allRows );
		$totalPages  = max( 1, (int) ceil( $totalRows / $perPage ) );
		$currentPage = min( $currentPage, $totalPages );
		$rows        = array_slice( $allRows, ( $currentPage - 1 ) * $perPage, $perPage );

		echo '<form method="get" style="margin:0 0 12px;">';
		echo '<input type="hidden" name="page" value="affilun-cloud-tools">';
		if ( $perPage !== self::AI_SCAN_DEFAULT_ROWS_PER_PAGE ) {
			echo '<input type="hidden" name="' . esc_attr( self::AI_SCAN_PER_PAGE_PARAM ) . '" value="' . esc_attr( (string) $perPage ) . '">';
		}
		echo '<label for="affilun-ai-scan-filter" class="screen-reader-text">' . esc_html( 'アフィリエイト状態で絞り込み' ) . '</label>';
		echo '<select id="affilun-ai-scan-filter" name="affilun_ai_scan_filter">';
		foreach ( self::aiScanFilterLabels() as $value => $label ) {
			if ( $filter === $value ) {
				echo '<option value="' . esc_attr( $value ) . '" selected="selected">' . esc_html( $label ) . '</option>';
			} else {
				echo '<option value="' . esc_attr( $value ) . '">' . esc_html( $label ) . '</option>';
			}
		}
		echo '</select> ';
		echo '<button class="button" type="submit">' . esc_html( '絞り込み' ) . '</button>';
		echo '</form>';

		echo '<form method="post" action="' . esc_url( self::resolveAdminUrl( 'admin-post.php' ) ) . '">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::AI_SCAN_ACTION ) . '">';
		echo '<input type="hidden" name="affilun_ai_scan_filter" value="' . esc_attr( $filter ) . '">';
		echo '<input type="hidden" name="' . esc_attr( self::AI_SCAN_PER_PAGE_PARAM ) . '" value="' . esc_attr( (string) $perPage ) . '">';
		self::renderNonceField( self::AI_SCAN_NONCE_ACTION, self::AI_SCAN_NONCE_FIELD );
		echo '<div class="tablenav top"><div class="alignleft actions bulkactions">';
		echo '<button class="button button-primary" type="submit">' . esc_html( 'AIスキャンを実行' ) . '</button>';
		echo '</div>';
		$this->renderAiScanPagination( $filter, $currentPage, $totalPages, $totalRows, $perPage );
		echo '<br class="clear"></div>';
		echo '<table class="wp-list-table widefat fixed striped posts">';
		echo '<thead><tr>';
		echo '<td class="manage-column column-cb check-column"><input type="checkbox" aria-label="' . esc_attr( 'すべて選択' ) . '"></td>';
		echo '<th scope="col">' . esc_html( 'タイトル' ) . '</th>';
		echo '<th scope="col">' . esc_html( 'ステータス' ) . '</th>';
		echo '<th scope="col">' . esc_html( '日付' ) . '</th>';
		echo '<th scope="col">' . esc_html( 'アフィリエイト' ) . '</th>';
		echo '</tr></thead><tbody>';
		if ( $rows === array() ) {
			echo '<tr><td colspan="5">' . esc_html( '対象の記事はありません。' ) . '</td></tr>';
		}
		$noCandidateMap = $this->aiScanNoCandidateMap();
		foreach ( $rows as $row ) {
			$postId     = self::positiveInt( $row['id'] ?? null ) ?? 0;
			$title      = self::stringValue( $row['title'] ?? '' );
			$status     = self::aiScanRowStatus( $row, $noCandidateMap );
			$canScan    = $status === '-';
			$statusText = self::postStatusLabel( self::stringValue( $row['status'] ?? '' ) );
			$date       = self::stringValue( $row['date'] ?? '' );
			$titleUrl   = self::postEditUrl( $postId, self::stringValue( $row['url'] ?? '' ) );
			echo '<tr>';
			echo '<th scope="row" class="check-column">';
			if ( $canScan ) {
				echo '<input type="checkbox" name="affilun_ai_scan_post_ids[]" value="' . esc_attr( (string) $postId ) . '">';
			}
			echo '</th>';
			echo '<td class="title column-title has-row-actions column-primary"><strong>';
			if ( $titleUrl !== '' ) {
				echo '<a href="' . esc_url( $titleUrl ) . '">' . esc_html( $title !== '' ? $title : '(no title)' ) . '</a>';
			} else {
				echo esc_html( $title !== '' ? $title : '(no title)' );
			}
			echo '</strong></td>';
			echo '<td>' . esc_html( $statusText ) . '</td>';
			echo '<td>' . esc_html( $date ) . '</td>';
			echo '<td>' . esc_html( $status ) . '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
		echo '<div class="tablenav bottom">';
		$this->renderAiScanPagination( $filter, $currentPage, $totalPages, $totalRows, $perPage );
		echo '<br class="clear"></div>';
		echo '</form>';
	}

	/**
	 * Render AI scan pagination.
	 *
	 * @param string $filter      Current filter.
	 * @param int    $currentPage Current page.
	 * @param int    $totalPages  Total pages.
	 * @param int    $totalRows   Total filtered rows.
	 * @param int    $perPage     Current page size.
	 */
	private function renderAiScanPagination( string $filter, int $currentPage, int $totalPages, int $totalRows, int $perPage ): void {
		echo '<div class="tablenav-pages">';
		echo '<span class="displaying-num">' . esc_html( sprintf( '%d件', $totalRows ) ) . '</span> ';
		$this->renderPerPageLinks(
			self::AI_SCAN_PER_PAGE_PARAM,
			$perPage,
			static fn ( int $option ): string => self::aiScanPageUrl( $filter, 1, $option )
		);
		if ( $currentPage > 1 ) {
			echo '<a class="button" href="' . esc_url( self::aiScanPageUrl( $filter, $currentPage - 1, $perPage ) ) . '">' . esc_html( '前へ' ) . '</a> ';
		}
		echo '<span class="paging-input">' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . '</span> ';
		if ( $currentPage < $totalPages ) {
			echo '<a class="button" href="' . esc_url( self::aiScanPageUrl( $filter, $currentPage + 1, $perPage ) ) . '">' . esc_html( '次へ' ) . '</a>';
		}
		echo '</div>';
	}

	/**
	 * Render 10/20/50 page-size links.
	 *
	 * @param string               $paramName Current page-size parameter name.
	 * @param int                  $current   Current page size.
	 * @param callable(int):string $urlBuilder URL builder for an option.
	 */
	private function renderPerPageLinks( string $paramName, int $current, callable $urlBuilder ): void {
		echo '<span class="affilun-per-page-links" data-affilun-per-page-param="' . esc_attr( $paramName ) . '">';
		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> ';
	}

	/**
	 * @param array<string,mixed> $query Query parameters.
	 */
	private function renderAiScanNotice( array $query ): void {
		if ( self::queryString( $query, 'affilun_ai_scan_queued' ) === '1' ) {
			$selected = self::queryInt( $query, 'affilun_ai_scan_selected', 0 );
			if ( $selected <= 0 ) {
				echo '<div class="notice notice-warning inline"><p>';
				echo esc_html( 'AIスキャン対象の記事が選択されていません。' );
				echo '</p></div>';
				return;
			}
			echo '<div class="notice notice-info inline"><p>';
			echo esc_html( sprintf( 'AIスキャンジョブを開始しました。対象: %d件。処理はバックグラウンドで少しずつ進みます。', $selected ) );
			echo '</p></div>';
			return;
		}
		if ( self::queryString( $query, 'affilun_ai_scan_done' ) !== '1' ) {
			return;
		}
		$selected    = self::queryInt( $query, 'affilun_ai_scan_selected', 0 );
		$scanned     = self::queryInt( $query, 'affilun_ai_scan_scanned', 0 );
		$updated     = self::queryInt( $query, 'affilun_ai_scan_updated', 0 );
		$noCandidate = self::queryInt( $query, 'affilun_ai_scan_no_candidate', 0 );
		$skipped     = self::queryInt( $query, 'affilun_ai_scan_skipped', 0 );
		$failed      = self::queryInt( $query, 'affilun_ai_scan_failed', 0 );
		if ( $selected <= 0 && $scanned <= 0 && $updated <= 0 && $noCandidate <= 0 && $skipped <= 0 && $failed <= 0 ) {
			echo '<div class="notice notice-warning inline"><p>';
			echo esc_html( 'AIスキャン対象の記事が選択されていません。' );
			echo '</p></div>';
			return;
		}
		echo '<div class="notice notice-success inline"><p>';
		echo esc_html( sprintf( 'AIスキャンを実行しました。選択: %d件、解析: %d件、追加: %d件、商品候補無し: %d件、スキップ: %d件、失敗: %d件。', $selected, $scanned, $updated, $noCandidate, $skipped, $failed ) );
		echo '</p></div>';
	}

	/**
	 * Render the latest or requested background AI scan job status.
	 *
	 * @param array<string,mixed> $query Query parameters.
	 */
	private function renderAiScanJobStatus( array $query ): void {
		$job = $this->aiScanJobForQuery( $query );
		if ( $job === null ) {
			return;
		}

		$status = self::stringValue( $job['status'] ?? '' );
		if ( $status === '' ) {
			return;
		}

		$done   = self::nonNegativeInt( $job['progress_done'] ?? 0 );
		$total  = self::nonNegativeInt( $job['progress_total'] ?? 0 );
		$result = isset( $job['result'] ) && is_array( $job['result'] ) ? $job['result'] : array();
		$class  = $status === 'succeeded' ? 'notice-success' : ( $status === 'failed' ? 'notice-error' : 'notice-info' );

		echo '<div class="notice ' . esc_attr( $class ) . ' inline"><p>';
		echo esc_html(
			sprintf(
				'AIスキャンジョブ: %s (%d / %d件)。解析: %d件、追加: %d件、商品候補無し: %d件、スキップ: %d件、失敗: %d件。',
				self::aiScanJobStatusLabel( $status ),
				$done,
				$total,
				self::nonNegativeInt( $result['scanned'] ?? 0 ),
				self::nonNegativeInt( $result['updated'] ?? 0 ),
				self::nonNegativeInt( $result['no_candidate'] ?? 0 ),
				self::nonNegativeInt( $result['skipped'] ?? 0 ),
				self::nonNegativeInt( $result['failed'] ?? 0 )
			)
		);
		if ( $status === 'queued' || $status === 'running' ) {
			echo ' ';
			echo esc_html( 'しばらくしてからこのページを再読み込みすると進捗を確認できます。' );
		}
		$lastError = self::stringValue( $job['last_error'] ?? '' );
		if ( $lastError !== '' ) {
			echo '<br>';
			echo esc_html( '最新エラー: ' . $lastError );
		}
		echo '</p></div>';
	}

	/**
	 * @param array<string,mixed> $query Query parameters.
	 *
	 * @return array<string,mixed>|null
	 */
	private function aiScanJobForQuery( array $query ): ?array {
		if ( $this->optionStore === null ) {
			return null;
		}
		$jobs = $this->optionStore->get( CloudAiPostScanRunner::JOBS_OPTION, array() );
		if ( ! is_array( $jobs ) || $jobs === array() ) {
			return null;
		}
		$jobId = self::queryString( $query, 'affilun_ai_scan_job_id' );
		if ( $jobId !== '' && isset( $jobs[ $jobId ] ) && is_array( $jobs[ $jobId ] ) ) {
			return $jobs[ $jobId ];
		}
		foreach ( $jobs as $job ) {
			$status = is_array( $job ) ? self::stringValue( $job['status'] ?? '' ) : '';
			if ( $status === 'queued' || $status === 'running' ) {
				return $job;
			}
		}
		return null;
	}

	/**
	 * Human-readable background AI scan status.
	 *
	 * @param string $status Stored job status.
	 */
	private static function aiScanJobStatusLabel( string $status ): string {
		return match ( $status ) {
			'queued' => '待機中',
			'running' => '実行中',
			'succeeded' => '完了',
			'failed' => '失敗',
			default => $status,
		};
	}

	/**
	 * @param mixed $value Candidate value.
	 */
	private static function nonNegativeInt( mixed $value ): int {
		return is_int( $value ) ? max( 0, $value ) : ( is_numeric( $value ) ? max( 0, (int) $value ) : 0 );
	}

	/**
	 * Filter AI scan post rows by affiliate status.
	 *
	 * @param string $filter Status filter.
	 *
	 * @return list<array<string,mixed>>
	 */
	private function filteredAiScanRows( string $filter ): array {
		$rows           = ( $this->aiScanPostRowsProvider )();
		$noCandidateMap = $this->aiScanNoCandidateMap();
		$filtered       = array();
		foreach ( $rows as $row ) {
			$status = self::aiScanRowStatus( $row, $noCandidateMap );
			if (
				$filter === self::AI_SCAN_FILTER_ALL
				|| ( $filter === self::AI_SCAN_FILTER_INSTALLED && $status === '設置済み' )
				|| ( $filter === self::AI_SCAN_FILTER_MISSING && $status === '-' )
				|| ( $filter === self::AI_SCAN_FILTER_NO_CANDIDATE && $status === '商品候補無し' )
			) {
				$filtered[] = $row;
			}
		}

		return $filtered;
	}

	/**
	 * @return array<string,string>
	 */
	private function aiScanNoCandidateMap(): array {
		if ( $this->optionStore === null ) {
			return array();
		}
		$value = $this->optionStore->get( CloudAiPostScanRunner::NO_CANDIDATE_OPTION, array() );
		if ( ! is_array( $value ) ) {
			return array();
		}

		$map = array();
		foreach ( $value as $key => $timestamp ) {
			$id = self::positiveInt( $key );
			if ( $id !== null && is_scalar( $timestamp ) ) {
				$map[ (string) $id ] = trim( (string) $timestamp );
			}
		}

		return $map;
	}

	/**
	 * @param array<string,mixed>  $row            Post row.
	 * @param array<string,string> $noCandidateMap No-candidate post map.
	 */
	private static function aiScanRowStatus( array $row, array $noCandidateMap ): string {
		$postId  = self::positiveInt( $row['id'] ?? null );
		$content = self::stringValue( $row['content'] ?? '' );
		if ( $content !== '' && CloudAiPostScanRunner::hasAffiliateContent( $content ) ) {
			return '設置済み';
		}
		if ( $postId !== null && array_key_exists( (string) $postId, $noCandidateMap ) ) {
			return '商品候補無し';
		}

		return '-';
	}

	/**
	 * @return array<string,string>
	 */
	private static function aiScanFilterLabels(): array {
		return array(
			self::AI_SCAN_FILTER_ALL          => 'すべて',
			self::AI_SCAN_FILTER_MISSING      => '未設置',
			self::AI_SCAN_FILTER_INSTALLED    => '設置済み',
			self::AI_SCAN_FILTER_NO_CANDIDATE => '商品候補無し',
		);
	}

	/**
	 * Clean AI scan status filter.
	 *
	 * @param string $value Raw filter value.
	 */
	private static function cleanAiScanFilter( string $value ): string {
		return array_key_exists( $value, self::aiScanFilterLabels() ) ? $value : self::AI_SCAN_FILTER_MISSING;
	}

	/**
	 * @return callable(): list<array<string,mixed>>
	 */
	private static function defaultAiScanPostRowsProvider(): callable {
		return static function (): array {
			if ( ! function_exists( 'get_posts' ) ) {
				return array();
			}
			$posts = get_posts(
				array(
					'post_type'      => 'post',
					'post_status'    => array( 'publish', 'draft', 'pending', 'future', 'private' ),
					'posts_per_page' => -1,
					'no_found_rows'  => true,
					'orderby'        => 'date',
					'order'          => 'DESC',
				)
			);
			$rows  = array();
			foreach ( $posts as $post ) {
				$rows[] = array(
					'id'      => (int) $post->ID,
					'title'   => (string) $post->post_title,
					'status'  => (string) $post->post_status,
					'date'    => (string) $post->post_date,
					'content' => (string) $post->post_content,
					'url'     => self::postEditUrl( (int) $post->ID ),
				);
			}
			return $rows;
		};
	}

	/**
	 * Build post edit URL.
	 *
	 * @param int    $postId   Post id.
	 * @param string $fallback Fallback URL.
	 */
	private static function postEditUrl( int $postId, string $fallback = '' ): string {
		if ( $fallback !== '' ) {
			return preg_match( '#\Ahttps?://#i', $fallback ) === 1 ? $fallback : self::resolveAdminUrl( $fallback );
		}
		if ( $postId <= 0 ) {
			return '';
		}
		if ( function_exists( 'get_edit_post_link' ) ) {
			$url = get_edit_post_link( $postId, '' );
			if ( is_string( $url ) && $url !== '' ) {
				return $url;
			}
		}

		return self::resolveAdminUrl( 'post.php?post=' . rawurlencode( (string) $postId ) . '&action=edit' );
	}

	/**
	 * Build AI scan page URL.
	 *
	 * @param string $filter Filter.
	 * @param int    $page   Page number.
	 * @param int    $perPage Page size.
	 */
	private static function aiScanPageUrl( string $filter, int $page, int $perPage ): string {
		$args = array(
			'page'                   => 'affilun-cloud-tools',
			'affilun_ai_scan_filter' => $filter,
			'affilun_ai_scan_paged'  => (string) max( 1, $page ),
		);
		if ( $perPage !== self::AI_SCAN_DEFAULT_ROWS_PER_PAGE ) {
			$args[ self::AI_SCAN_PER_PAGE_PARAM ] = (string) $perPage;
		}
		$url = self::resolveAdminUrl( 'admin.php' );
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $url );
		}

		return $url . '?' . http_build_query( $args );
	}

	/**
	 * Build top affiliate candidate page URL.
	 *
	 * @param int $page    Page number.
	 * @param int $perPage Page size.
	 */
	private static function topAffiliatePageUrl( int $page, int $perPage ): string {
		$args = array(
			'page'                        => 'affilun-cloud-tools',
			'affilun_top_affiliate_paged' => (string) max( 1, $page ),
		);
		if ( $perPage !== self::TOP_AFFILIATE_DEFAULT_GROUPS_PER_PAGE ) {
			$args[ self::TOP_AFFILIATE_PER_PAGE_PARAM ] = (string) $perPage;
		}
		$url = self::resolveAdminUrl( 'admin.php' );
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $url );
		}

		return $url . '?' . http_build_query( $args );
	}

	/**
	 * Render Japanese post status label.
	 *
	 * @param string $status WordPress post status.
	 */
	private static function postStatusLabel( string $status ): string {
		return match ( $status ) {
			'publish' => '公開済み',
			'draft' => '下書き',
			'pending' => 'レビュー待ち',
			'future' => '予約済み',
			'private' => '非公開',
			default => $status !== '' ? $status : '-',
		};
	}

	/**
	 * Render campaign section.
	 *
	 * @param CloudSettings       $cloud    Cloud settings.
	 * @param PluginSettings      $settings Plugin settings.
	 * @param array<string,mixed> $query    Query parameters.
	 */
	private function renderCampaignSection( CloudSettings $cloud, PluginSettings $settings, array $query ): void {
		echo '<h2>' . esc_html( 'キャンペーン提案' ) . '</h2>';
		echo '<p>' . esc_html( '提携先キャンペーンに該当する保存商品と、追加候補の商品を表示します。リンク監視とは分離し、自動公開や自動保存は行いません。' ) . '</p>';
		$providerOptions = $this->campaignProviderOptions( $settings );
		$provider        = self::queryString( $query, 'affilun_campaign_provider' );
		if ( ! array_key_exists( $provider, $providerOptions ) ) {
			$provider = array_key_first( $providerOptions ) ?: 'amazon';
		}
		$date = self::queryString( $query, 'affilun_campaign_date' );
		if ( $date === '' ) {
			$date = gmdate( 'Y-m-d' );
		}
		echo '<form method="get"><input type="hidden" name="page" value="affilun-cloud-tools">';
		echo '<label for="affilun-campaign-provider">' . esc_html( 'プロバイダー' ) . '</label> ';
		echo '<select id="affilun-campaign-provider" name="affilun_campaign_provider">';
		foreach ( $providerOptions as $key => $label ) {
			$selected = $key === $provider ? ' selected="selected"' : '';
			echo '<option value="' . esc_attr( $key ) . '"'
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $selected is a hard-coded selected attribute or empty string.
				. $selected
				. '>' . esc_html( $label ) . '</option>';
		}
		echo '</select> ';
		echo '<input type="date" name="affilun_campaign_date" value="' . esc_attr( $date ) . '"> ';
		self::renderNonceField( self::revenueRefreshNonceAction(), self::REVENUE_REFRESH_NONCE_FIELD );
		echo '<button class="button button-secondary" type="submit">' . esc_html( 'キャンペーン提案を更新' ) . '</button></form>';
		$this->renderRevenueStatusFlash( $query );
		if ( ! $cloud->isConfigured() || ! isset( $query['affilun_campaign_provider'] ) ) {
			return;
		}
		$products = $this->campaignProducts( $provider );
		try {
			if ( ! self::verifyQueryNonce( $query, self::revenueRefreshNonceAction(), self::REVENUE_REFRESH_NONCE_FIELD ) ) {
				echo '<div class="notice notice-error inline"><p>';
				echo esc_html( 'キャンペーン提案の更新リクエストを確認できませんでした。もう一度実行してください。' );
				echo '</p></div>';
				return;
			}
			$suggestions = $this->campaigns->fetchOpportunities(
				$cloud,
				$provider,
				$date,
				$products,
				time(),
				self::nonce()
			);
			$this->renderCampaignSuggestions( $suggestions );
		} catch ( Throwable $unused ) {
			unset( $unused );
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( 'affilun Cloud のキャンペーン提案取得に失敗しました。' );
			echo '</p></div>';
		}
	}

	/**
	 * Render a status-update flash for the opportunity work queue.
	 *
	 * @param array<string,mixed> $query Query parameters.
	 */
	private function renderRevenueStatusFlash( array $query ): void {
		if ( self::queryString( $query, 'affilun_revenue_status_updated' ) === '1' ) {
			echo '<div class="notice notice-success inline"><p>';
			echo esc_html( 'キャンペーン提案の状態を更新しました。' );
			echo '</p></div>';
			return;
		}
		if ( self::queryString( $query, 'affilun_revenue_status_error' ) === '1' ) {
			echo '<div class="notice notice-error inline"><p>';
			echo esc_html( 'キャンペーン提案の状態更新に失敗しました。時間をおいて再実行してください。' );
			echo '</p></div>';
		}
	}

	/**
	 * Resolve campaign provider options from enabled providers.
	 *
	 * @param PluginSettings $settings Plugin settings.
	 *
	 * @return array<string,string>
	 */
	private function campaignProviderOptions( PluginSettings $settings ): array {
		$options = array();
		foreach ( self::CAMPAIGN_PROVIDERS as $key => $label ) {
			$provider = $settings->provider( $key );
			if ( $provider !== null && $provider->enabled() && $provider->isConfigured() ) {
				$options[ $key ] = $label;
			}
		}

		return $options !== array() ? $options : self::CAMPAIGN_PROVIDERS;
	}

	/**
	 * Build saved-product context for campaign opportunities.
	 *
	 * @param string $provider Provider.
	 *
	 * @return list<array<string,mixed>>
	 */
	private function campaignProducts( string $provider ): array {
		if ( $this->productRepository === null ) {
			return array();
		}

		$result   = $this->productRepository->findPaginated(
			new ProductListQuery(
				1,
				50,
				$provider,
				null,
				null,
				null,
				ProductListQuery::LIFECYCLE_STATE_ACTIVE,
				ProductListQuery::SORT_KEY_UPDATED_AT,
				ProductListQuery::SORT_ORDER_DESC
			)
		);
		$products = array();
		foreach ( $result->items() as $record ) {
			$metadata = array();
			if ( $record->id() !== null ) {
				$metadata['wp_product_id'] = $record->id();
			}
			if ( $record->priceAmount() !== null ) {
				$metadata['price_amount'] = $record->priceAmount();
			}
			if ( $record->availabilityStatus() !== null ) {
				$metadata['availability_status'] = $record->availabilityStatus();
			}
			$products[] = array(
				'provider'         => $record->provider(),
				'provider_item_id' => $record->providerItemId(),
				'title'            => $record->title(),
				'detail_url'       => $record->detailUrl(),
				'affiliate_url'    => $record->affiliateUrl(),
				'metadata'         => $metadata,
			);
		}

		return $products;
	}

	/**
	 * Build a nonce action for one opportunity status transition.
	 *
	 * @param string $opportunityId Opportunity id.
	 * @param string $status        New status.
	 */
	public static function revenueOpportunityStatusNonceAction( string $opportunityId, string $status ): string {
		return 'affilun_revenue_opportunity_status_' . $opportunityId . '_' . $status;
	}

	/**
	 * Build a nonce action for refreshing one link-health job.
	 *
	 * @param string $jobId Job id.
	 */
	private static function linkHealthJobNonceAction( string $jobId ): string {
		return 'affilun_link_health_job_' . $jobId;
	}

	/**
	 * Build a nonce action for starting saved-link health scans.
	 *
	 * @param string $pageSlug Admin page slug.
	 */
	private static function linkHealthSavedNonceAction( string $pageSlug ): string {
		return 'affilun_link_health_saved_' . $pageSlug;
	}

	/**
	 * Build a nonce action for refreshing revenue opportunities.
	 */
	private static function revenueRefreshNonceAction(): string {
		return 'affilun_revenue_opportunities_refresh';
	}

	/**
	 * Build a nonce action for refreshing top affiliate candidates.
	 */
	private static function topAffiliateRefreshNonceAction(): string {
		return 'affilun_top_affiliate_candidates_refresh';
	}

	/**
	 * Render campaign suggestions.
	 *
	 * @param list<CloudCampaignSuggestion> $suggestions Suggestions.
	 */
	private function renderCampaignSuggestions( array $suggestions ): void {
		if ( $suggestions === array() ) {
			echo '<p>' . esc_html( 'キャンペーン候補はありません。' ) . '</p>';
			return;
		}
		$hasAddableProduct = false;
		echo '<table class="wp-list-table widefat striped"><thead><tr>';
		foreach ( array( 'Provider', 'Type', 'Label', 'Microcopy', 'Period', 'Offer', '操作' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead><tbody>';
		foreach ( $suggestions as $suggestion ) {
			echo '<tr>';
			echo '<td>' . esc_html( $suggestion->provider() ) . '</td>';
			echo '<td>' . esc_html( $suggestion->isProductOpportunity() ? '保存商品' : '共有キャンペーン' ) . '</td>';
			echo '<td>' . esc_html( $suggestion->label() ) . '</td>';
			echo '<td>' . esc_html( $suggestion->microcopy() ) . '</td>';
			echo '<td>' . esc_html( $suggestion->startAt() . ' - ' . $suggestion->endAt() ) . '</td>';
			echo '<td>' . esc_html( self::campaignOfferSummary( $suggestion ) ) . '</td>';
			echo '<td>';
			echo '<input class="regular-text" type="text" value="' . esc_attr( $suggestion->microcopy() ) . '"> ';
			echo '<button type="button" class="button button-small" data-affilun-copy="' . esc_attr( $suggestion->microcopy() ) . '" onclick="if (navigator.clipboard) { navigator.clipboard.writeText(this.dataset.aflCopy || \'\'); }">';
			echo esc_html( 'コピー' ) . '</button> ';
			$productId = $suggestion->wpProductId();
			if ( $productId !== null ) {
				echo '<a class="button button-small" href="' . esc_url( self::productEditUrl( $productId ) ) . '">'
					. esc_html( '商品を開く' ) . '</a>';
			} elseif ( $suggestion->isAddableProduct() ) {
				$hasAddableProduct = true;
				echo '<button type="button" class="button button-primary affilun-top-affiliate-add" data-affilun-product-payload="' . esc_attr( self::campaignProductPayload( $suggestion ) ) . '">'
					. esc_html( '追加' ) . '</button>';
				echo '<div class="affilun-top-affiliate-add-status" aria-live="polite" style="font-size:12px;margin-top:4px;"></div>';
			}
			echo '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
		if ( $hasAddableProduct ) {
			$this->renderTopAffiliateCandidateScript();
		}
	}

	/**
	 * Build an add-product payload for a campaign suggestion.
	 *
	 * @param CloudCampaignSuggestion $suggestion Suggestion.
	 */
	private static function campaignProductPayload( CloudCampaignSuggestion $suggestion ): string {
		$offer    = $suggestion->offer();
		$price    = array();
		$amount   = is_scalar( $offer['price_amount'] ?? null ) ? trim( (string) $offer['price_amount'] ) : '';
		$currency = is_scalar( $offer['currency'] ?? null ) ? trim( (string) $offer['currency'] ) : '';
		if ( $amount !== '' ) {
			$price = array(
				'amount'   => $amount,
				'currency' => $currency !== '' ? $currency : 'JPY',
				'label'    => AdminPriceFormatter::format( $amount, $currency !== '' ? $currency : 'JPY' ),
			);
		}

		$snapshot = array(
			'title'      => $suggestion->title(),
			'detail_url' => $suggestion->detailUrl(),
		);
		if ( $suggestion->affiliateUrl() !== '' ) {
			$snapshot['affiliate_url'] = $suggestion->affiliateUrl();
		}
		if ( $price !== array() ) {
			$snapshot['price'] = $price;
		}

		$payload = wp_json_encode(
			array(
				'provider'         => $suggestion->provider(),
				'provider_item_id' => $suggestion->providerItemId(),
				'snapshot'         => $snapshot,
			)
		);

		return is_string( $payload ) ? $payload : '{}';
	}

	/**
	 * Summarize provider offer metadata for the campaign table.
	 *
	 * @param CloudCampaignSuggestion $suggestion Suggestion.
	 */
	private static function campaignOfferSummary( CloudCampaignSuggestion $suggestion ): string {
		$parts = array();
		if ( $suggestion->providerItemId() !== '' ) {
			$parts[] = $suggestion->providerItemId();
		}
		$offer = $suggestion->offer();
		if ( isset( $offer['deal_type'] ) && is_scalar( $offer['deal_type'] ) && (string) $offer['deal_type'] !== '' ) {
			$parts[] = (string) $offer['deal_type'];
		}
		if ( isset( $offer['savings_percentage'] ) && is_scalar( $offer['savings_percentage'] ) ) {
			$parts[] = sprintf( '%s%% off', (string) $offer['savings_percentage'] );
		}
		if ( isset( $offer['price_amount'] ) && is_scalar( $offer['price_amount'] ) ) {
			$currency = is_scalar( $offer['currency'] ?? null ) && (string) $offer['currency'] !== ''
				? (string) $offer['currency']
				: 'JPY';
			$parts[]  = AdminPriceFormatter::format( (string) $offer['price_amount'], $currency );
		}
		if ( $suggestion->source() !== '' ) {
			$parts[] = $suggestion->source();
		}

		return $parts !== array() ? implode( ' / ', $parts ) : '-';
	}

	/**
	 * Resolve an admin URL via WordPress when available.
	 *
	 * @param string $path Admin path.
	 */
	private static function resolveAdminUrl( string $path ): string {
		if ( function_exists( 'admin_url' ) ) {
			return (string) admin_url( $path );
		}

		return $path;
	}

	/**
	 * Render a nonce field with a pure-unit fallback.
	 *
	 * @param string $action Nonce action.
	 * @param string $name   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="' . esc_attr( $action ) . '">';
	}

	/**
	 * Verify a nonce from query parameters.
	 *
	 * @param array<string,mixed> $query  Query parameters.
	 * @param string              $action Expected nonce action.
	 * @param string              $field  Nonce field.
	 */
	private static function verifyQueryNonce( array $query, string $action, string $field ): bool {
		$nonce = self::queryString( $query, $field );
		if ( $nonce === '' || ! function_exists( 'wp_verify_nonce' ) ) {
			return false;
		}
		return (bool) wp_verify_nonce( $nonce, $action );
	}

	/**
	 * Product edit admin URL.
	 *
	 * @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;
	}

	/**
	 * Read scalar query value.
	 *
	 * @param array<string,mixed> $query Query.
	 * @param string              $key   Key.
	 */
	private static function queryString( array $query, string $key ): string {
		$value = $query[ $key ] ?? '';
		return is_scalar( $value ) ? trim( (string) $value ) : '';
	}

	/**
	 * Read non-negative integer query value.
	 *
	 * @param array<string,mixed> $query    Query.
	 * @param string              $key      Key.
	 * @param int                 $fallback Fallback value.
	 */
	private static function queryInt( array $query, string $key, int $fallback ): int {
		$value = self::queryString( $query, $key );
		if ( $value === '' || ! ctype_digit( $value ) ) {
			return $fallback;
		}

		return (int) $value;
	}

	/**
	 * Read a page-size query value restricted to the exposed choices.
	 *
	 * @param array<string,mixed> $query    Query.
	 * @param string              $key      Key.
	 * @param int                 $fallback Fallback page size.
	 */
	private static function queryPerPage( array $query, string $key, int $fallback ): int {
		$value = self::queryInt( $query, $key, $fallback );
		return in_array( $value, self::PER_PAGE_OPTIONS, true ) ? $value : $fallback;
	}

	/**
	 * Convert scalar value to string.
	 *
	 * @param mixed $value Candidate value.
	 */
	private static function stringValue( mixed $value ): string {
		return is_scalar( $value ) ? trim( (string) $value ) : '';
	}

	/**
	 * Generate request nonce.
	 *
	 * @throws \Exception When random generation fails.
	 */
	private static function nonce(): string {
		return bin2hex( random_bytes( 8 ) );
	}
}
