<?php
/**
 * Migration and growth admin-page renderer.
 *
 * @package Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Application\Migration\LegacyProductLinkImporter;
use Affilun\Application\Migration\LegacyProductLinkScanner;
use Affilun\Application\Migration\PostScanItem;
use Affilun\Infrastructure\Persistence\OptionStore;

/**
 * Renders Phase 4 dry-run and legacy rewrite workflows.
 */
final class MigrationPageRenderer {

	private const MENU_SLUG = 'affilun-migration';

	private const KEY_ACTION = 'affilun_phase4_action';

	private const KEY_OFFSET = 'affilun_offset';

	private const KEY_LIMIT = 'affilun_limit';

	private const ACTION_DRY_RUN = 'dry_run';

	private const ACTION_BACKUP = 'backup';

	private const ACTION_IMPORT_PREVIEW = 'import_preview';

	private const ACTION_IMPORT_APPLY = 'import_apply';

	private const IMPORT_APPLY_NONCE_FIELD = '_affilun_legacy_import_apply_nonce';

	private const IMPORT_APPLY_NONCE_ACTION = 'affilun_legacy_import_apply';

	private const BACKUP_NONCE_FIELD = '_affilun_legacy_backup_nonce';

	private const BACKUP_NONCE_ACTION = 'affilun_legacy_backup';

	private const MIGRATION_JOB_OPTION = 'affilun_legacy_migration_job';

	/**
	 * Option storage.
	 */
	private OptionStore $options;

	/**
	 * Legacy scanner.
	 */
	private LegacyProductLinkScanner $legacyScanner;

	/**
	 * Explicit legacy importer.
	 */
	private LegacyProductLinkImporter $legacyImporter;

	/**
	 * @var callable(int,int): list<PostScanItem>
	 */
	private $postProvider;

	/**
	 * @var callable(int,string): bool
	 */
	private $postUpdater;

	/**
	 * @param OptionStore                    $options            Option storage.
	 * @param callable|null                  $postProvider       Optional post provider.
	 * @param LegacyProductLinkScanner|null  $legacyScanner      Optional legacy scanner.
	 * @param mixed|null                     $opportunityScanner Deprecated opportunity scanner.
	 * @param LegacyProductLinkImporter|null $legacyImporter     Optional legacy importer.
	 * @param callable|null                  $postUpdater        Optional post updater.
	 *
	 * @phpstan-param (callable(int,int): list<PostScanItem>)|null $postProvider
	 * @phpstan-param (callable(int,string): bool)|null $postUpdater
	 */
	public function __construct(
		OptionStore $options,
		?callable $postProvider = null,
		?LegacyProductLinkScanner $legacyScanner = null,
		mixed $opportunityScanner = null,
		?LegacyProductLinkImporter $legacyImporter = null,
		?callable $postUpdater = null
	) {
		$this->options       = $options;
		$this->postProvider  = $postProvider ?? self::defaultPostProvider();
		$this->legacyScanner = $legacyScanner ?? new LegacyProductLinkScanner();
		unset( $opportunityScanner );
		$this->legacyImporter = $legacyImporter ?? new LegacyProductLinkImporter();
		$this->postUpdater    = $postUpdater ?? self::defaultPostUpdater();
	}

	/**
	 * Render the page or export payload.
	 *
	 * @param array<string,mixed> $query Query parameters.
	 */
	public function render( array $query = array() ): void {
		$action = self::queryString( $query, self::KEY_ACTION );
		$offset = self::queryInt( $query, self::KEY_OFFSET, 0 );
		$limit  = self::queryInt( $query, self::KEY_LIMIT, 10 );

		echo '<div class="wrap">';
		echo '<h1>' . esc_html( '移行 / スキャン' ) . '</h1>';
		echo '<p>' . esc_html( '既存記事からaffilun以外の商品リンクを検出し移行します。' ) . '</p>';
		$this->renderEmbedded( $query, self::MENU_SLUG, '' );
		echo '</div>';
	}

	/**
	 * Render migration controls/results without the outer admin wrapper.
	 *
	 * @param array<string,mixed> $query    Query/request parameters.
	 * @param string              $menuSlug Admin page slug for embedded controls.
	 * @param string              $anchor   Optional URL fragment to preserve tab focus.
	 */
	public function renderEmbedded( array $query = array(), string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void {
		$action = self::queryString( $query, self::KEY_ACTION );
		$offset = self::queryInt( $query, self::KEY_OFFSET, 0 );
		$limit  = self::queryInt( $query, self::KEY_LIMIT, 10 );

		if ( $action === self::ACTION_DRY_RUN ) {
			$this->renderControls( $offset, $limit, $menuSlug, $anchor );
			$this->renderDryRunReport( $offset, $limit, $menuSlug, $anchor );
		} elseif ( $action === self::ACTION_IMPORT_PREVIEW ) {
			$job = $this->createPreviewJob( $offset, $limit );
			$this->renderControls( $offset, $limit, $menuSlug, $anchor, $job );
			$this->renderMigrationJob( $job );
		} elseif ( $action === self::ACTION_BACKUP ) {
			$job = $this->backupLatestJob( $query );
			$this->renderControls( $offset, $limit, $menuSlug, $anchor, $job );
			$this->renderMigrationJob( $job );
		} elseif ( $action === self::ACTION_IMPORT_APPLY ) {
			$job = $this->rewriteLatestJob( $query );
			$this->renderControls( $offset, $limit, $menuSlug, $anchor, $job );
			$this->renderMigrationJob( $job );
		} else {
			$job = $this->latestJob();
			$this->renderControls( $offset, $limit, $menuSlug, $anchor, $job );
			if ( $job !== null ) {
				$this->renderMigrationJob( $job );
			}
		}
	}

	/**
	 * Render workflow controls.
	 *
	 * @param int                      $offset   Offset.
	 * @param int                      $limit    Batch limit.
	 * @param string                   $menuSlug Admin page slug for embedded controls.
	 * @param string                   $anchor   Optional URL fragment to preserve tab focus.
	 * @param array<string,mixed>|null $job Latest migration job.
	 */
	private function renderControls(
		int $offset,
		int $limit,
		string $menuSlug = self::MENU_SLUG,
		string $anchor = '',
		?array $job = null
	): void {
		if ( $menuSlug !== self::MENU_SLUG ) {
			$this->renderWorkflowButtons( $offset, $limit, $menuSlug, $anchor, $job );
			return;
		}

		echo '<h2>' . esc_html( 'プレビュー → バックアップ → リライト' ) . '</h2>';
		$this->renderWorkflowButtons( $offset, $limit, $menuSlug, $anchor, $job );
	}

	/**
	 * Render stateful migration workflow buttons.
	 *
	 * @param int                      $offset   Offset.
	 * @param int                      $limit    Batch limit.
	 * @param string                   $menuSlug Admin page slug.
	 * @param string                   $anchor   Optional anchor.
	 * @param array<string,mixed>|null $job      Latest migration job.
	 */
	private function renderWorkflowButtons( int $offset, int $limit, string $menuSlug, string $anchor, ?array $job ): void {
		$status       = self::jobStatus( $job );
		$hasChanges   = $job !== null && self::jobChanges( $job ) !== array();
		$previewDone  = in_array( $status, array( 'previewed', 'backed_up', 'rewritten' ), true );
		$backupDone   = in_array( $status, array( 'backed_up', 'rewritten' ), true );
		$rewriteDone  = $status === 'rewritten';
		$backupReady  = $previewDone && ! $backupDone && $hasChanges;
		$rewriteReady = $backupDone && ! $rewriteDone && $hasChanges;

		echo '<div class="affilun-migration-workflow" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">';
		$this->renderWorkflowButton( 'プレビュー', self::ACTION_IMPORT_PREVIEW, $offset, $limit, $menuSlug, $anchor, $previewDone, null, null );
		echo '<span aria-hidden="true">' . esc_html( '→' ) . '</span>';
		$this->renderWorkflowButton( 'バックアップ', self::ACTION_BACKUP, $offset, $limit, $menuSlug, $anchor, ! $backupReady, self::BACKUP_NONCE_ACTION, self::BACKUP_NONCE_FIELD );
		echo '<span aria-hidden="true">' . esc_html( '→' ) . '</span>';
		$this->renderWorkflowButton( 'リライト', self::ACTION_IMPORT_APPLY, $offset, $limit, $menuSlug, $anchor, ! $rewriteReady, self::IMPORT_APPLY_NONCE_ACTION, self::IMPORT_APPLY_NONCE_FIELD );
		echo '</div>';
		if ( $rewriteDone ) {
			echo '<p>' . esc_html( 'リライトは完了しています。' ) . '</p>';
		}
	}

	/**
	 * Render a workflow button as GET or POST form.
	 *
	 * @param string      $label      Label.
	 * @param string      $action     Action.
	 * @param int         $offset     Offset.
	 * @param int         $limit      Batch limit.
	 * @param string      $menuSlug   Admin page slug.
	 * @param string      $anchor     Optional anchor.
	 * @param bool        $disabled   Whether disabled.
	 * @param string|null $nonceAction Nonce action for POST buttons.
	 * @param string|null $nonceField Nonce field for POST buttons.
	 */
	private function renderWorkflowButton(
		string $label,
		string $action,
		int $offset,
		int $limit,
		string $menuSlug,
		string $anchor,
		bool $disabled,
		?string $nonceAction,
		?string $nonceField
	): void {
		$method = $nonceAction === null ? 'get' : 'post';
		$url    = self::adminPageUrl( array( 'page' => $menuSlug ) ) . $anchor;
		echo '<form method="' . esc_attr( $method ) . '" action="' . esc_url( $url ) . '" style="display:inline-block;margin:0;">';
		echo '<input type="hidden" name="page" value="' . esc_attr( $menuSlug ) . '">';
		echo '<input type="hidden" name="' . esc_attr( self::KEY_ACTION ) . '" value="' . esc_attr( $action ) . '">';
		echo '<input type="hidden" name="' . esc_attr( self::KEY_OFFSET ) . '" value="' . esc_attr( (string) max( 0, $offset ) ) . '">';
		echo '<input type="hidden" name="' . esc_attr( self::KEY_LIMIT ) . '" value="' . esc_attr( (string) max( 1, min( 25, $limit ) ) ) . '">';
		if ( $nonceAction !== null && $nonceField !== null ) {
			self::renderNonceField( $nonceAction, $nonceField );
		}
		$classes = $action === self::ACTION_IMPORT_APPLY ? 'button button-primary' : 'button';
		echo '<button type="submit" class="' . esc_attr( $classes ) . '"' . ( $disabled ? ' disabled aria-disabled="true"' : '' ) . '>' . esc_html( $label ) . '</button>';
		echo '</form>';
	}

	/**
	 * Render one action link.
	 *
	 * @param string $label  Label.
	 * @param string $action   Action.
	 * @param int    $offset   Offset.
	 * @param int    $limit    Limit.
	 * @param string $menuSlug Admin page slug for generated links.
	 * @param string $anchor   Optional URL fragment to preserve tab focus.
	 */
	private function renderActionLink( string $label, string $action, int $offset, int $limit, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void {
		$url = self::adminPageUrl(
			array(
				'page'           => $menuSlug,
				self::KEY_ACTION => $action,
				self::KEY_OFFSET => (string) max( 0, $offset ),
				self::KEY_LIMIT  => (string) max( 1, min( 25, $limit ) ),
			)
		) . $anchor;
		echo '<a class="button" href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a>';
	}

	/**
	 * Render legacy dry-run report.
	 *
	 * @param int    $offset   Offset.
	 * @param int    $limit    Limit.
	 * @param string $menuSlug Admin page slug for generated links.
	 * @param string $anchor   Optional URL fragment to preserve tab focus.
	 */
	private function renderDryRunReport( int $offset, int $limit, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void {
		$provider = $this->postProvider;
		$posts    = $provider( $offset, max( 1, min( 25, $limit ) ) );
		$report   = $this->legacyScanner->dryRun( $posts );

		echo '<h2>' . esc_html( '移行候補ドライラン' ) . '</h2>';
		echo '<p>' . esc_html( 'スキャン記事数: ' . (string) $report['posts_scanned'] ) . '</p>';
		$this->renderLegacyMatchesTable( $report['matches'] );

		if ( count( $posts ) >= $limit ) {
			echo '<p>';
			$this->renderActionLink( '次のバッチ', self::ACTION_DRY_RUN, $offset + $limit, $limit, $menuSlug, $anchor );
			echo '</p>';
		}
	}

	/**
	 * Create and persist a preview job.
	 *
	 * @param int $offset Offset.
	 * @param int $limit  Batch limit.
	 *
	 * @return array<string,mixed>
	 */
	private function createPreviewJob( int $offset, int $limit ): array {
		$provider = $this->postProvider;
		$posts    = $provider( $offset, max( 1, min( 25, $limit ) ) );
		$preview  = $this->legacyImporter->preview( $posts );
		$job      = array(
			'id'            => self::newJobId(),
			'status'        => 'previewed',
			'offset'        => max( 0, $offset ),
			'limit'         => max( 1, min( 25, $limit ) ),
			'created_at'    => gmdate( DATE_ATOM ),
			'previewed_at'  => gmdate( DATE_ATOM ),
			'posts_scanned' => $preview['posts_scanned'],
			'changes'       => $preview['changes'],
			'backups'       => array(),
			'result'        => array(),
		);
		$this->saveJob( $job );
		return $job;
	}

	/**
	 * Back up current post content for the latest preview job.
	 *
	 * @param array<string,mixed> $query  Request data.
	 *
	 * @return array<string,mixed>|null
	 */
	private function backupLatestJob( array $query ): ?array {
		$job = $this->latestJob();
		if ( $job === null || self::jobStatus( $job ) !== 'previewed' ) {
			return $job;
		}
		if ( ! self::verifyNonce( self::queryString( $query, self::BACKUP_NONCE_FIELD ), self::BACKUP_NONCE_ACTION ) ) {
			$job['error'] = 'backup_nonce';
			return $job;
		}

		$postsById = $this->postsById( self::jobOffset( $job ), self::jobLimit( $job ) );
		$backups   = array();
		foreach ( self::changedPostIds( self::jobChanges( $job ) ) as $postId ) {
			$post = $postsById[ $postId ] ?? null;
			if ( ! $post instanceof PostScanItem ) {
				continue;
			}
			$backups[] = array(
				'post_id'        => $post->id(),
				'post_title'     => $post->title(),
				'content'        => $post->content(),
				'content_sha256' => hash( 'sha256', $post->content() ),
				'created_at'     => gmdate( DATE_ATOM ),
			);
		}

		$job['status']       = $backups === array() ? 'previewed' : 'backed_up';
		$job['backed_up_at'] = gmdate( DATE_ATOM );
		$job['backups']      = $backups;
		$this->saveJob( $job );
		return $job;
	}

	/**
	 * Rewrite posts from the latest backed-up job.
	 *
	 * @param array<string,mixed> $query Request data.
	 *
	 * @return array<string,mixed>|null
	 */
	private function rewriteLatestJob( array $query ): ?array {
		$job = $this->latestJob();
		if ( $job === null || self::jobStatus( $job ) !== 'backed_up' ) {
			return $job;
		}
		if ( ! self::verifyNonce( self::queryString( $query, self::IMPORT_APPLY_NONCE_FIELD ), self::IMPORT_APPLY_NONCE_ACTION ) ) {
			$job['error'] = 'rewrite_nonce';
			return $job;
		}

		$postsById     = $this->postsById( self::jobOffset( $job ), self::jobLimit( $job ) );
		$changesByPost = self::changesByPost( self::jobChanges( $job ) );
		$updated       = 0;
		$skipped       = 0;
		$failed        = 0;
		foreach ( self::jobBackups( $job ) as $backup ) {
			$postId = self::positiveInt( $backup['post_id'] ?? null );
			$post   = $postId !== null ? ( $postsById[ $postId ] ?? null ) : null;
			if ( ! $post instanceof PostScanItem || $postId === null ) {
				++$skipped;
				continue;
			}
			$expectedHash = self::fieldString( $backup, 'content_sha256' );
			if ( $expectedHash === '' || hash( 'sha256', $post->content() ) !== $expectedHash ) {
				++$skipped;
				continue;
			}
			$newContent = $this->legacyImporter->rewriteContent( $post->content(), $changesByPost[ $postId ] ?? array() );
			if ( $newContent === $post->content() ) {
				++$skipped;
				continue;
			}
			$updater = $this->postUpdater;
			if ( $updater( $postId, $newContent ) ) {
				++$updated;
			} else {
				++$failed;
			}
		}

		$job['status']       = 'rewritten';
		$job['rewritten_at'] = gmdate( DATE_ATOM );
		$job['result']       = array(
			'updated_posts' => $updated,
			'skipped_posts' => $skipped,
			'failed_posts'  => $failed,
		);
		$this->saveJob( $job );
		return $job;
	}

	/**
	 * Render current migration job.
	 *
	 * @param array<string,mixed>|null $job Migration job.
	 */
	private function renderMigrationJob( ?array $job ): void {
		if ( $job === null ) {
			return;
		}
		if ( self::fieldString( $job, 'error' ) !== '' ) {
			echo '<div class="notice notice-error"><p>' . esc_html( '操作を確認できませんでした。画面を再読み込みしてもう一度実行してください。' ) . '</p></div>';
		}
		$status = self::jobStatus( $job );
		if ( $status === 'previewed' ) {
			echo '<h2>' . esc_html( 'プレビュー結果' ) . '</h2>';
			echo '<p>' . esc_html( 'この段階では記事本文を変更していません。候補を確認してからバックアップを実行してください。' ) . '</p>';
		} elseif ( $status === 'backed_up' ) {
			echo '<h2>' . esc_html( 'バックアップ済み' ) . '</h2>';
			echo '<p>' . esc_html( '対象投稿の本文をWordPressデータベースに保存しました。内容を確認してからリライトを実行してください。' ) . '</p>';
		} elseif ( $status === 'rewritten' ) {
			$result = self::jobResult( $job );
			echo '<h2>' . esc_html( 'リライト結果' ) . '</h2>';
			echo '<p>' . esc_html(
				sprintf(
					'更新: %d件 / スキップ: %d件 / 失敗: %d件',
					(int) ( $result['updated_posts'] ?? 0 ),
					(int) ( $result['skipped_posts'] ?? 0 ),
					(int) ( $result['failed_posts'] ?? 0 )
				)
			) . '</p>';
		}
		$this->renderImportChangesTable( self::jobChanges( $job ) );
	}

	/**
	 * Load the latest migration job from WordPress options.
	 *
	 * @return array<string,mixed>|null
	 */
	private function latestJob(): ?array {
		$value = $this->options->get( self::MIGRATION_JOB_OPTION, null );
		return is_array( $value ) ? $value : null;
	}

	/**
	 * Persist the latest migration job.
	 *
	 * @param array<string,mixed> $job Migration job.
	 */
	private function saveJob( array $job ): void {
		$this->options->updateNonAutoload( self::MIGRATION_JOB_OPTION, $job );
	}

	/**
	 * Load current batch posts keyed by ID.
	 *
	 * @param int $offset Offset.
	 * @param int $limit  Batch limit.
	 *
	 * @return array<int,PostScanItem>
	 */
	private function postsById( int $offset, int $limit ): array {
		$provider = $this->postProvider;
		$posts    = $provider( $offset, max( 1, min( 25, $limit ) ) );
		$byId     = array();
		foreach ( $posts as $post ) {
			$byId[ $post->id() ] = $post;
		}

		return $byId;
	}

	/**
	 * Build a new job id.
	 */
	private static function newJobId(): string {
		if ( function_exists( 'wp_generate_uuid4' ) ) {
			return (string) wp_generate_uuid4();
		}

		return gmdate( 'YmdHis' ) . '-' . bin2hex( random_bytes( 4 ) );
	}

	/**
	 * @param array<string,mixed>|null $job Migration job.
	 */
	private static function jobStatus( ?array $job ): string {
		$status = is_array( $job ) ? self::fieldString( $job, 'status' ) : '';
		return in_array( $status, array( 'previewed', 'backed_up', 'rewritten' ), true ) ? $status : '';
	}

	/**
	 * @param array<string,mixed> $job Migration job.
	 *
	 * @return list<array<string,mixed>>
	 */
	private static function jobChanges( array $job ): array {
		$changes = $job['changes'] ?? array();
		if ( ! is_array( $changes ) ) {
			return array();
		}

		$normalized = array();
		foreach ( $changes as $change ) {
			if ( is_array( $change ) ) {
				$normalized[] = $change;
			}
		}

		return $normalized;
	}

	/**
	 * @param array<string,mixed> $job Migration job.
	 *
	 * @return list<array<string,mixed>>
	 */
	private static function jobBackups( array $job ): array {
		$backups = $job['backups'] ?? array();
		if ( ! is_array( $backups ) ) {
			return array();
		}

		$normalized = array();
		foreach ( $backups as $backup ) {
			if ( is_array( $backup ) ) {
				$normalized[] = $backup;
			}
		}

		return $normalized;
	}

	/**
	 * @param array<string,mixed> $job Migration job.
	 *
	 * @return array<string,mixed>
	 */
	private static function jobResult( array $job ): array {
		$result = $job['result'] ?? array();
		return is_array( $result ) ? $result : array();
	}

	/**
	 * @param array<string,mixed> $job Migration job.
	 */
	private static function jobOffset( array $job ): int {
		return self::positiveInt( $job['offset'] ?? null ) ?? 0;
	}

	/**
	 * @param array<string,mixed> $job Migration job.
	 */
	private static function jobLimit( array $job ): int {
		return self::positiveInt( $job['limit'] ?? null ) ?? 10;
	}

	/**
	 * @param list<array<string,mixed>> $changes Changes.
	 *
	 * @return list<int>
	 */
	private static function changedPostIds( array $changes ): array {
		$ids = array();
		foreach ( $changes as $change ) {
			$postId = self::positiveInt( $change['post_id'] ?? null );
			if ( $postId !== null && ! in_array( $postId, $ids, true ) ) {
				$ids[] = $postId;
			}
		}

		return $ids;
	}

	/**
	 * @param list<array<string,mixed>> $changes Changes.
	 *
	 * @return array<int,list<array<string,mixed>>>
	 */
	private static function changesByPost( array $changes ): array {
		$byPost = array();
		foreach ( $changes as $change ) {
			$postId = self::positiveInt( $change['post_id'] ?? null );
			if ( $postId === null ) {
				continue;
			}
			if ( ! array_key_exists( $postId, $byPost ) ) {
				$byPost[ $postId ] = array();
			}
			$byPost[ $postId ][] = $change;
		}

		return $byPost;
	}

	/**
	 * Render import changes.
	 *
	 * @param list<array<string,mixed>> $changes Changes.
	 */
	private function renderImportChangesTable( array $changes ): void {
		if ( $changes === array() ) {
			echo '<p>' . esc_html( '適用可能な移行候補は見つかりませんでした。' ) . '</p>';
			return;
		}

		echo '<table class="wp-list-table widefat striped"><thead><tr>';
		foreach ( array( '投稿 ID', 'タイトル', '種類', '検出元', '信頼度', '変換前', '変換後' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead><tbody>';
		foreach ( $changes as $change ) {
			echo '<tr>';
			echo '<td>' . esc_html( self::fieldString( $change, 'post_id' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $change, 'post_title' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $change, 'kind' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $change, 'source' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $change, 'confidence' ) ) . '</td>';
			$originalPreview    = self::shortPreview( self::fieldString( $change, 'original' ) );
			$replacementPreview = self::shortPreview( self::fieldString( $change, 'replacement' ) );
			echo '<td><code>' . esc_html( $originalPreview ) . '</code></td>';
			echo '<td><code>' . esc_html( $replacementPreview ) . '</code></td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
	}

	/**
	 * Render legacy matches.
	 *
	 * @param list<array<string,mixed>> $matches Matches.
	 */
	private function renderLegacyMatchesTable( array $matches ): void {
		if ( $matches === array() ) {
			echo '<p>' . esc_html( '移行候補は見つかりませんでした。' ) . '</p>';
			return;
		}

		echo '<table class="wp-list-table widefat striped"><thead><tr>';
		foreach ( array( '投稿 ID', 'タイトル', '種類', '検出元', '信頼度', 'プレビュー' ) as $heading ) {
			echo '<th scope="col">' . esc_html( $heading ) . '</th>';
		}
		echo '</tr></thead><tbody>';
		foreach ( $matches as $match ) {
			echo '<tr>';
			echo '<td>' . esc_html( self::fieldString( $match, 'post_id' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $match, 'post_title' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $match, 'kind' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $match, 'source' ) ) . '</td>';
			echo '<td>' . esc_html( self::fieldString( $match, 'confidence' ) ) . '</td>';
			echo '<td><code>' . esc_html( self::fieldString( $match, 'preview' ) ) . '</code></td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
	}

	/**
	 * 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="">';
	}

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

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

	/**
	 * Shorten preview text for admin table cells.
	 *
	 * @param string $value Value.
	 */
	private static function shortPreview( string $value ): string {
		$value = trim( preg_replace( '/\s+/', ' ', $value ) ?? $value );
		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
			return mb_strlen( $value ) > 140 ? mb_substr( $value, 0, 137 ) . '...' : $value;
		}

		return strlen( $value ) > 140 ? substr( $value, 0, 137 ) . '...' : $value;
	}

	/**
	 * Read a report field as string.
	 *
	 * @param array<string,mixed> $row Row.
	 * @param string              $key Key.
	 */
	private static function fieldString( array $row, string $key ): string {
		$value = $row[ $key ] ?? '';
		return is_scalar( $value ) ? (string) $value : '';
	}

	/**
	 * Query string helper.
	 *
	 * @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 ) : '';
	}

	/**
	 * Query int helper.
	 *
	 * @param array<string,mixed> $query    Query.
	 * @param string              $key      Key.
	 * @param int                 $fallback Fallback.
	 */
	private static function queryInt( array $query, string $key, int $fallback ): int {
		$value = $query[ $key ] ?? null;
		if ( is_int( $value ) ) {
			return max( 0, $value );
		}
		if ( is_string( $value ) && ctype_digit( $value ) ) {
			return max( 0, (int) $value );
		}
		return $fallback;
	}

	/**
	 * Normalize a positive integer.
	 *
	 * @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;
	}

	/**
	 * Build an admin URL.
	 *
	 * @param array<string,string> $args Args.
	 */
	private static function adminPageUrl( array $args ): string {
		$url = function_exists( 'admin_url' ) ? (string) admin_url( 'admin.php' ) : 'admin.php';
		if ( function_exists( 'add_query_arg' ) ) {
			return (string) add_query_arg( $args, $url );
		}
		return $url . '?' . http_build_query( $args );
	}

	/**
	 * Default WordPress post updater.
	 *
	 * @phpstan-return callable(int,string): bool
	 */
	private static function defaultPostUpdater(): callable {
		return static function ( int $postId, string $content ): bool {
			if ( ! function_exists( 'wp_update_post' ) ) {
				return false;
			}

			$result = wp_update_post(
				array(
					'ID'           => $postId,
					'post_content' => $content,
				),
				true
			);

			return ! is_wp_error( $result );
		};
	}

	/**
	 * Default WordPress post provider.
	 *
	 * @phpstan-return callable(int,int): list<PostScanItem>
	 */
	private static function defaultPostProvider(): callable {
		return static function ( int $offset, int $limit ): array {
			if ( ! function_exists( 'get_posts' ) ) {
				return array();
			}

			$rawPosts = get_posts(
				array(
					'post_type'      => 'post',
					'post_status'    => array( 'publish', 'draft', 'pending', 'future', 'private' ),
					'posts_per_page' => max( 1, min( 25, $limit ) ),
					'offset'         => max( 0, $offset ),
					'orderby'        => 'ID',
					'order'          => 'ASC',
				)
			);

			$items = array();
			foreach ( is_array( $rawPosts ) ? $rawPosts : array() as $post ) {
				$id      = is_object( $post ) && property_exists( $post, 'ID' )
					? (int) $post->ID
					: 0;
				$title   = is_object( $post ) && property_exists( $post, 'post_title' )
					? (string) $post->post_title
					: '';
				$content = is_object( $post ) && property_exists( $post, 'post_content' )
					? (string) $post->post_content
					: '';
				if ( $id > 0 ) {
					$items[] = new PostScanItem( $id, $title, $content );
				}
			}
			return $items;
		};
	}
}
