<?php
/**
 * Cloud-backed AI scan runner for existing WordPress posts.
 *
 * @package Affilun\Application\Cloud
 */

declare(strict_types=1);

namespace Affilun\Application\Cloud;

use Affilun\Application\Ai\CloudArticleSuggestionProvider;
use Affilun\Application\Product\ProductUrlDraft;
use Affilun\Application\Product\UseCase\SaveProductUseCase;
use Affilun\Application\Product\UseCase\SearchProductsUseCase;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Settings\PluginSettings;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\Exception\ProviderException;
use Affilun\Domain\Provider\SearchProviderInterface;
use Affilun\Domain\Search\SearchQuery;
use Affilun\Infrastructure\Persistence\OptionStore;
use Throwable;

/**
 * Runs the Cloud AI scan and appends affilun product cards to posts.
 */
final class CloudAiPostScanRunner {

	public const NO_CANDIDATE_OPTION = 'affilun_cloud_ai_scan_no_candidate_posts';

	public const JOBS_OPTION = 'affilun_cloud_ai_scan_jobs';

	public const CRON_HOOK = 'affilun_cron_cloud_ai_post_scan';

	public const DEFAULT_JOB_BATCH_SIZE = 2;

	/**
	 * Providers that should not be queried by automatic Cloud AI post scans.
	 *
	 * Mercari is a synthetic search-link provider rather than a real item-search
	 * source, so scanning it creates noisy saveable candidates even when no
	 * product result exists.
	 *
	 * @var list<string>
	 */
	private const AI_SCAN_SEARCH_EXCLUDED_PROVIDERS = array( 'mercari' );

	/**
	 * @var callable(list<int>): list<array<string,mixed>>
	 */
	private $postLoader;

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

	/**
	 * @var callable(PluginSettings,string,string): list<string>
	 */
	private $keywordExtractor;

	/**
	 * @var callable(PluginSettings,string,string,list<string>,list<array<string,mixed>>): array<string,mixed>
	 */
	private $affiliateScanEvaluator;

	/**
	 * @var list<array{post_id:int|null,message:string,occurred_at:string}>
	 */
	private array $lastRunErrors = array();

	/**
	 * @param SettingsRepository $settings         Settings repository.
	 * @param OptionStore        $options          Option store.
	 * @param ProviderRegistry   $providerRegistry Provider registry.
	 * @param SaveProductUseCase $saveProduct      Product save use case.
	 * @param callable|null      $postLoader       Optional post loader.
	 * @param callable|null      $postUpdater      Optional post updater.
	 * @param callable|null      $keywordExtractor Optional Cloud keyword extractor.
	 * @param callable|null      $affiliateScanEvaluator Optional Cloud affiliate scan evaluator.
	 *
	 * @phpstan-param (callable(list<int>): list<array<string,mixed>>)|null $postLoader
	 * @phpstan-param (callable(int,string): bool)|null $postUpdater
	 * @phpstan-param (callable(PluginSettings,string,string): list<string>)|null $keywordExtractor
	 * @phpstan-param (callable(PluginSettings,string,string,list<string>,list<array<string,mixed>>): array<string,mixed>)|null $affiliateScanEvaluator
	 */
	public function __construct(
		private SettingsRepository $settings,
		private OptionStore $options,
		private ProviderRegistry $providerRegistry,
		private SaveProductUseCase $saveProduct,
		?callable $postLoader = null,
		?callable $postUpdater = null,
		?callable $keywordExtractor = null,
		?callable $affiliateScanEvaluator = null
	) {
		$this->postLoader             = $postLoader ?? self::defaultPostLoader();
		$this->postUpdater            = $postUpdater ?? self::defaultPostUpdater();
		$this->keywordExtractor       = $keywordExtractor ?? self::defaultKeywordExtractor();
		$this->affiliateScanEvaluator = $affiliateScanEvaluator ?? self::defaultAffiliateScanEvaluator();
	}

	/**
	 * Execute AI scan for selected posts.
	 *
	 * @param list<int> $postIds Selected post IDs.
	 *
	 * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int}
	 */
	public function run( array $postIds ): array {
		$this->lastRunErrors = array();
		$ids                 = self::normalizeIds( $postIds );
		if ( $ids === array() ) {
			return self::emptyResult();
		}

		$settings  = $this->settings->load();
		$providers = $this->enabledSearchProviders( $settings );
		if ( $providers === array() || ! $settings->cloud()->isConfigured() ) {
			$result             = self::emptyResult();
			$result['selected'] = count( $ids );
			$result['failed']   = count( $ids );
			foreach ( $ids as $postId ) {
				$this->lastRunErrors[] = self::jobError( $postId, 'Cloud settings or provider credentials are not configured.' );
			}
			return $result;
		}

		$searchUseCase      = new SearchProductsUseCase( $providers );
		$posts              = ( $this->postLoader )( $ids );
		$noCandidate        = $this->loadNoCandidateMap();
		$result             = self::emptyResult();
		$result['selected'] = count( $ids );

		foreach ( $posts as $post ) {
			$postId  = self::positiveInt( $post['id'] ?? null );
			$title   = self::stringValue( $post['title'] ?? '' );
			$content = self::stringValue( $post['content'] ?? '' );
			if ( $postId === null || $content === '' || self::hasAffiliateContent( $content ) ) {
				++$result['skipped'];
				continue;
			}

			try {
				$keywords   = ( $this->keywordExtractor )( $settings, $title, $content );
				$candidates = $this->searchProviderCandidates( $searchUseCase, array_keys( $providers ), $keywords );
				$decision   = ( $this->affiliateScanEvaluator )( $settings, $title, $content, $keywords, self::candidatePayloads( $candidates ) );
				$selected   = self::selectedCandidates( $decision, $candidates );
				++$result['scanned'];
				if ( $selected === array() ) {
					$noCandidate[ (string) $postId ] = gmdate( DATE_ATOM );
					++$result['no_candidate'];
					continue;
				}

				$snapshot   = array_shift( $selected );
				$urlDrafts  = self::urlDraftsFor( $selected );
				$saveResult = $this->saveProduct->execute( $snapshot, $urlDrafts );
				$productId  = $saveResult->record()->id();
				if ( $productId === null ) {
					++$result['failed'];
					$this->lastRunErrors[] = self::jobError( $postId, 'Product save did not return an id.' );
					continue;
				}

				$updatedContent = self::appendProductCard( $content, $productId );
				if ( ( $this->postUpdater )( $postId, $updatedContent ) ) {
					unset( $noCandidate[ (string) $postId ] );
					++$result['updated'];
				} else {
					++$result['failed'];
					$this->lastRunErrors[] = self::jobError( $postId, 'WordPress post update did not return a saved post id.' );
				}
			} catch ( Throwable $error ) {
				++$result['failed'];
				$this->lastRunErrors[] = self::jobError( $postId, self::boundedErrorMessage( $error ) );
			}
		}

		$this->saveNoCandidateMap( $noCandidate );
		return $result;
	}

	/**
	 * Latest per-post errors captured by {@see run()}.
	 *
	 * @return list<array{post_id:int|null,message:string,occurred_at:string}>
	 */
	public function lastRunErrors(): array {
		return $this->lastRunErrors;
	}

	/**
	 * Enqueue selected posts for background AI scanning.
	 *
	 * @param list<int> $postIds Selected post IDs.
	 *
	 * @return array<string,mixed>
	 */
	public function enqueue( array $postIds ): array {
		$ids = self::normalizeIds( $postIds, 500 );
		$job = self::newJob( $ids );
		if ( $ids === array() ) {
			$job['status']      = 'succeeded';
			$job['finished_at'] = gmdate( DATE_ATOM );
		}

		$jobs               = $this->loadJobs();
		$jobs[ $job['id'] ] = $job;
		$this->saveJobs( $jobs );
		return $job;
	}

	/**
	 * Process one queued/running AI scan job batch.
	 *
	 * @param int $batchSize Maximum posts to process this tick.
	 *
	 * @return array<string,mixed>|null
	 */
	public function tick( int $batchSize = self::DEFAULT_JOB_BATCH_SIZE ): ?array {
		$jobs  = $this->loadJobs();
		$jobId = $this->nextRunnableJobId( $jobs );
		if ( $jobId === null ) {
			return null;
		}

		$job                  = $jobs[ $jobId ];
		$job['status']        = 'running';
		$job['updated_at']    = gmdate( DATE_ATOM );
		$job['last_error']    = null;
		$pending              = self::jobPendingIds( $job );
		$batchIds             = array_slice( $pending, 0, max( 1, $batchSize ) );
		$job['pending_ids']   = array_values( array_slice( $pending, count( $batchIds ) ) );
		$result               = $this->run( $batchIds );
		$job['result']        = self::mergeResults( self::jobResult( $job ), $result );
		$job['errors']        = self::mergeJobErrors( self::jobErrors( $job ), $this->lastRunErrors() );
		$job['failed_ids']    = self::mergeFailedIds( self::jobFailedIds( $job ), $this->lastRunErrors() );
		$job['progress_done'] = self::intValue( $job['progress_done'] ?? 0 ) + count( $batchIds );
		$job['updated_at']    = gmdate( DATE_ATOM );
		if ( $job['pending_ids'] === array() ) {
			$job['status']      = self::intValue( $job['result']['failed'] ?? 0 ) > 0 ? 'failed' : 'succeeded';
			$job['finished_at'] = gmdate( DATE_ATOM );
			$job['last_error']  = $job['status'] === 'failed' ? self::latestJobErrorMessage( self::jobErrors( $job ) ) : null;
		}

		$jobs[ $jobId ] = $job;
		$this->saveJobs( $jobs );
		return $job;
	}

	/**
	 * Whether any background AI scan job still needs processing.
	 */
	public function hasPendingJobs(): bool {
		return $this->nextRunnableJobId( $this->loadJobs() ) !== null;
	}

	/**
	 * Fetch a stored background job.
	 *
	 * @param string $jobId Job identifier.
	 *
	 * @return array<string,mixed>|null
	 */
	public function job( string $jobId ): ?array {
		$jobs = $this->loadJobs();
		return isset( $jobs[ $jobId ] ) && is_array( $jobs[ $jobId ] ) ? $jobs[ $jobId ] : null;
	}

	/**
	 * Latest stored background job, if any.
	 *
	 * @return array<string,mixed>|null
	 */
	public function latestJob(): ?array {
		$jobs = $this->loadJobs();
		if ( $jobs === array() ) {
			return null;
		}
		uasort(
			$jobs,
			static fn ( array $a, array $b ): int => strcmp(
				self::stringValue( $b['created_at'] ?? '' ),
				self::stringValue( $a['created_at'] ?? '' )
			)
		);
		$job = reset( $jobs );
		return is_array( $job ) ? $job : null;
	}

	/**
	 * Detect whether content already contains an affiliate/product card.
	 *
	 * @param string $content Post content.
	 */
	public static function hasAffiliateContent( string $content ): bool {
		if (
			str_contains( $content, 'wp:affilun/product-card' )
			|| preg_match( '/\[affilun_product_card\b/i', $content ) === 1
			|| preg_match( '/amazon_affi_url|["\']asin["\']|item\.rakuten\.co\.jp|shopping\.yahoo\.co\.jp/i', $content ) === 1
		) {
			return true;
		}

		return false;
	}

	/**
	 * Append a Gutenberg product-card block to post content.
	 *
	 * @param string $content   Post content.
	 * @param int    $productId Saved affilun product id.
	 */
	public static function appendProductCard( string $content, int $productId ): string {
		$card    = '<!-- wp:affilun/product-card {"productId":' . (string) $productId . '} /-->';
		$trimmed = rtrim( $content );
		if ( $trimmed === '' ) {
			return $card;
		}

		return $trimmed . "\n\n" . $card;
	}

	/**
	 * @return array<string,string>
	 */
	public function noCandidateMap(): array {
		return $this->loadNoCandidateMap();
	}

	/**
	 * @param SearchProductsUseCase $searchUseCase Product search use case.
	 * @param list<string>          $providers     Provider keys.
	 * @param list<string>          $keywords      Search keywords in priority order.
	 *
	 * @return array<string,ProductSnapshot>
	 */
	private function searchProviderCandidates( SearchProductsUseCase $searchUseCase, array $providers, array $keywords ): array {
		$candidates = array();
		foreach ( $keywords as $keyword ) {
			$trimmed = trim( $keyword );
			if ( $trimmed === '' ) {
				continue;
			}
			foreach ( $providers as $provider ) {
				if ( self::isExcludedAiScanSearchProvider( $provider ) ) {
					continue;
				}
				$query = new SearchQuery(
					$trimmed,
					1,
					5,
					null,
					null,
					null,
					null,
					array(),
					'ja-JP',
					'JPY',
					'cloud_ai_post_scan'
				);
				try {
					$bundle = $searchUseCase->execute( $query, $provider );
				} catch ( Throwable $error ) {
					if ( $error instanceof ProviderException ) {
						continue;
					}
					throw $error;
				}
				foreach ( $bundle->results() as $result ) {
					foreach ( $result->items() as $snapshot ) {
						$key = self::candidateKey( $snapshot );
						if ( ! array_key_exists( $key, $candidates ) ) {
							$candidates[ $key ] = $snapshot;
						}
					}
				}
			}
		}

		return array_slice( $candidates, 0, 60, true );
	}

	/**
	 * Whether an enabled provider should be skipped by automatic AI scans.
	 *
	 * @param string $provider Provider key.
	 */
	private static function isExcludedAiScanSearchProvider( string $provider ): bool {
		return in_array( strtolower( trim( $provider ) ), self::AI_SCAN_SEARCH_EXCLUDED_PROVIDERS, true );
	}

	/**
	 * @param array<string,ProductSnapshot> $candidates Candidate snapshots.
	 *
	 * @return list<array<string,mixed>>
	 */
	private static function candidatePayloads( array $candidates ): array {
		$payloads = array();
		foreach ( $candidates as $snapshot ) {
			$payloads[] = self::candidatePayload( $snapshot );
		}
		return $payloads;
	}

	/**
	 * @param ProductSnapshot $snapshot Candidate snapshot.
	 *
	 * @return array<string,mixed>
	 */
	private static function candidatePayload( ProductSnapshot $snapshot ): array {
		$price        = $snapshot->price();
		$availability = $snapshot->availability();
		$payload      = array(
			'provider'            => self::boundedString( $snapshot->provider(), 80 ),
			'provider_item_id'    => self::boundedString( $snapshot->providerProductId()->value(), 255 ),
			'title'               => self::boundedString( $snapshot->title(), 500 ),
			'brand'               => self::boundedNullableString( $snapshot->brand(), 255 ),
			'description'         => self::boundedNullableString( $snapshot->description(), 1200 ),
			'detail_url'          => self::boundedNullableString( $snapshot->detailUrl(), 2048 ),
			'affiliate_url'       => self::boundedNullableString( $snapshot->affiliateUrl(), 2048 ),
			'image_url'           => self::boundedNullableString( $snapshot->primaryImage(), 2048 ),
			'price_amount'        => $price !== null ? self::boundedString( $price->amount(), 64 ) : null,
			'price_currency'      => $price !== null ? self::boundedString( $price->currency(), 16 ) : null,
			'price_label'         => $price !== null ? self::boundedNullableString( $price->label(), 120 ) : null,
			'availability_status' => self::boundedNullableString( $availability->status(), 64 ),
			'rating'              => self::boundedNullableString( $snapshot->rating(), 32 ),
			'review_count'        => $snapshot->reviewCount(),
			'categories'          => self::boundedStringList( $snapshot->categories(), 20, 255 ),
		);
		$attributes   = self::boundedAttributeMap( $snapshot->attributes(), 40, 120, 512 );
		if ( $attributes !== array() ) {
			$payload['attributes'] = $attributes;
		}

		return $payload;
	}

	/**
	 * @param string $value  Raw value.
	 * @param int    $length Maximum character length.
	 */
	private static function boundedString( string $value, int $length ): string {
		$value = trim( $value );
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $value, 0, $length );
		}
		return substr( $value, 0, $length );
	}

	/**
	 * @param string|null $value  Raw value.
	 * @param int         $length Maximum character length.
	 */
	private static function boundedNullableString( ?string $value, int $length ): ?string {
		if ( $value === null ) {
			return null;
		}
		$value = self::boundedString( $value, $length );
		return $value !== '' ? $value : null;
	}

	/**
	 * @param list<string> $values     Raw values.
	 * @param int          $countLimit Maximum number of values.
	 * @param int          $itemLimit  Maximum character length per value.
	 *
	 * @return list<string>
	 */
	private static function boundedStringList( array $values, int $countLimit, int $itemLimit ): array {
		$result = array();
		foreach ( array_slice( $values, 0, $countLimit ) as $value ) {
			$value = self::boundedString( $value, $itemLimit );
			if ( $value !== '' ) {
				$result[] = $value;
			}
		}
		return $result;
	}

	/**
	 * @param array<string, scalar|null> $attributes Raw attributes.
	 * @param int                        $countLimit Maximum number of attributes.
	 * @param int                        $keyLimit   Maximum character length per key.
	 * @param int                        $valueLimit Maximum character length per string value.
	 *
	 * @return array<string, scalar|null>
	 */
	private static function boundedAttributeMap( array $attributes, int $countLimit, int $keyLimit, int $valueLimit ): array {
		$result = array();
		foreach ( $attributes as $key => $value ) {
			if ( count( $result ) >= $countLimit ) {
				break;
			}
			$key = self::boundedString( $key, $keyLimit );
			if ( $key === '' ) {
				continue;
			}
			if ( is_string( $value ) ) {
				$result[ $key ] = self::boundedString( $value, $valueLimit );
			} else {
				$result[ $key ] = $value;
			}
		}
		return $result;
	}

	/**
	 * @param array<string,mixed>           $decision   Cloud decision payload.
	 * @param array<string,ProductSnapshot> $candidates Candidate snapshots by key.
	 *
	 * @return list<ProductSnapshot>
	 */
	private static function selectedCandidates( array $decision, array $candidates ): array {
		$group = isset( $decision['selected_group'] ) && is_array( $decision['selected_group'] )
			? $decision['selected_group']
			: null;
		if ( $group === null || self::floatValue( $group['confidence'] ?? null ) < 0.7 ) {
			return array();
		}
		$selected = array();
		$items    = isset( $group['items'] ) && is_array( $group['items'] ) ? $group['items'] : array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			if (
				self::floatValue( $item['confidence'] ?? null ) < 0.7
				|| self::floatValue( $item['relevance_score'] ?? null ) < 0.6
				|| self::floatValue( $item['same_product_score'] ?? null, 1.0 ) < 0.65
			) {
				continue;
			}
			$key = self::candidateKeyFromParts(
				self::stringValue( $item['provider'] ?? '' ),
				self::stringValue( $item['provider_item_id'] ?? '' )
			);
			if ( $key !== '' && array_key_exists( $key, $candidates ) ) {
				$selected[] = $candidates[ $key ];
			}
		}

		return $selected;
	}

	/**
	 * @param list<ProductSnapshot> $snapshots Selected secondary snapshots.
	 *
	 * @return list<ProductUrlDraft>
	 */
	private static function urlDraftsFor( array $snapshots ): array {
		$drafts = array();
		foreach ( $snapshots as $snapshot ) {
			$drafts[] = new ProductUrlDraft(
				$snapshot->provider(),
				$snapshot->providerProductId()->value(),
				$snapshot->detailUrl(),
				'provider'
			);
			if ( $snapshot->affiliateUrl() !== null ) {
				$drafts[] = new ProductUrlDraft(
					$snapshot->provider(),
					$snapshot->providerProductId()->value(),
					$snapshot->affiliateUrl(),
					'provider'
				);
			}
		}
		return $drafts;
	}

	/**
	 * Build a stable candidate key from a snapshot.
	 *
	 * @param ProductSnapshot $snapshot Candidate snapshot.
	 */
	private static function candidateKey( ProductSnapshot $snapshot ): string {
		return self::candidateKeyFromParts( $snapshot->provider(), $snapshot->providerProductId()->value() );
	}

	/**
	 * Build a stable candidate key from provider fields.
	 *
	 * @param string $provider       Provider key.
	 * @param string $providerItemId Provider item id.
	 */
	private static function candidateKeyFromParts( string $provider, string $providerItemId ): string {
		$provider       = trim( $provider );
		$providerItemId = trim( $providerItemId );
		if ( $provider === '' || $providerItemId === '' ) {
			return '';
		}
		return strtolower( $provider ) . "\0" . $providerItemId;
	}

	/**
	 * Normalize a Cloud score into the 0.0-1.0 range.
	 *
	 * @param mixed $value        Raw score.
	 * @param float $defaultScore Default score.
	 */
	private static function floatValue( mixed $value, float $defaultScore = 0.0 ): float {
		if ( is_int( $value ) || is_float( $value ) || ( is_string( $value ) && is_numeric( $value ) ) ) {
			return max( 0.0, min( 1.0, (float) $value ) );
		}
		return $defaultScore;
	}

	/**
	 * @param PluginSettings $settings Plugin settings.
	 *
	 * @return array<string,SearchProviderInterface>
	 */
	private function enabledSearchProviders( PluginSettings $settings ): array {
		$providers = array();
		foreach ( $this->providerRegistry->all() as $key => $provider ) {
			$credentials = $settings->provider( $key );
			if (
				$provider instanceof SearchProviderInterface
				&& $credentials !== null
				&& $credentials->enabled()
				&& $credentials->isConfigured()
			) {
				$providers[ $key ] = $provider;
			}
		}

		return $providers;
	}

	/**
	 * @return array<string,string>
	 */
	private function loadNoCandidateMap(): array {
		$value = $this->options->get( self::NO_CANDIDATE_OPTION, array() );
		if ( ! is_array( $value ) ) {
			return array();
		}

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

		return $map;
	}

	/**
	 * @param array<string,string> $map No-candidate map.
	 */
	private function saveNoCandidateMap( array $map ): void {
		$this->options->updateNonAutoload( self::NO_CANDIDATE_OPTION, $map );
	}

	/**
	 * @param list<int> $postIds Post IDs.
	 * @param int       $limit   Maximum number of IDs to keep.
	 *
	 * @return list<int>
	 */
	private static function normalizeIds( array $postIds, int $limit = 20 ): array {
		$ids = array();
		foreach ( $postIds as $postId ) {
			$id = self::positiveInt( $postId );
			if ( $id !== null && ! in_array( $id, $ids, true ) ) {
				$ids[] = $id;
			}
		}

		return array_slice( $ids, 0, max( 1, $limit ) );
	}

	/**
	 * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int}
	 */
	private static function emptyResult(): array {
		return array(
			'selected'     => 0,
			'scanned'      => 0,
			'updated'      => 0,
			'no_candidate' => 0,
			'skipped'      => 0,
			'failed'       => 0,
		);
	}

	/**
	 * @param list<int> $ids Post IDs.
	 *
	 * @return array<string,mixed>
	 */
	private static function newJob( array $ids ): array {
		$now                = gmdate( DATE_ATOM );
		$result             = self::emptyResult();
		$result['selected'] = count( $ids );
		return array(
			'id'             => bin2hex( random_bytes( 12 ) ),
			'status'         => 'queued',
			'post_ids'       => $ids,
			'pending_ids'    => $ids,
			'progress_done'  => 0,
			'progress_total' => count( $ids ),
			'result'         => $result,
			'created_at'     => $now,
			'updated_at'     => $now,
			'finished_at'    => null,
			'last_error'     => null,
			'errors'         => array(),
			'failed_ids'     => array(),
		);
	}

	/**
	 * @return array<string,array<string,mixed>>
	 */
	private function loadJobs(): array {
		$value = $this->options->get( self::JOBS_OPTION, array() );
		if ( ! is_array( $value ) ) {
			return array();
		}
		$jobs = array();
		foreach ( $value as $jobId => $job ) {
			if ( is_string( $jobId ) && is_array( $job ) ) {
				$jobs[ $jobId ] = $job;
			}
		}
		return $jobs;
	}

	/**
	 * @param array<string,array<string,mixed>> $jobs Jobs by id.
	 */
	private function saveJobs( array $jobs ): void {
		uasort(
			$jobs,
			static fn ( array $a, array $b ): int => strcmp(
				self::stringValue( $b['created_at'] ?? '' ),
				self::stringValue( $a['created_at'] ?? '' )
			)
		);
		$this->options->updateNonAutoload( self::JOBS_OPTION, array_slice( $jobs, 0, 20, true ) );
	}

	/**
	 * @param array<string,array<string,mixed>> $jobs Jobs by id.
	 */
	private function nextRunnableJobId( array $jobs ): ?string {
		foreach ( $jobs as $jobId => $job ) {
			$status = self::stringValue( $job['status'] ?? '' );
			if ( $status === 'queued' || $status === 'running' ) {
				return $jobId;
			}
		}
		return null;
	}

	/**
	 * @param array<string,mixed> $job Stored job.
	 *
	 * @return list<int>
	 */
	private static function jobPendingIds( array $job ): array {
		$pending = isset( $job['pending_ids'] ) && is_array( $job['pending_ids'] ) ? $job['pending_ids'] : array();
		return self::normalizeIds( $pending, 500 );
	}

	/**
	 * @param array<string,mixed> $job Stored job.
	 *
	 * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int}
	 */
	private static function jobResult( array $job ): array {
		$result = isset( $job['result'] ) && is_array( $job['result'] ) ? $job['result'] : array();
		return array(
			'selected'     => self::intValue( $result['selected'] ?? 0 ),
			'scanned'      => self::intValue( $result['scanned'] ?? 0 ),
			'updated'      => self::intValue( $result['updated'] ?? 0 ),
			'no_candidate' => self::intValue( $result['no_candidate'] ?? 0 ),
			'skipped'      => self::intValue( $result['skipped'] ?? 0 ),
			'failed'       => self::intValue( $result['failed'] ?? 0 ),
		);
	}

	/**
	 * @param array<string,mixed> $job Stored job.
	 *
	 * @return list<array{post_id:int|null,message:string,occurred_at:string}>
	 */
	private static function jobErrors( array $job ): array {
		$value = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array();
		$rows  = array();
		foreach ( $value as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$message = self::stringValue( $row['message'] ?? '' );
			if ( $message === '' ) {
				continue;
			}
			$postId = self::positiveInt( $row['post_id'] ?? null );
			$rows[] = array(
				'post_id'     => $postId,
				'message'     => self::boundedString( $message, 300 ),
				'occurred_at' => self::stringValue( $row['occurred_at'] ?? '' ) ?: gmdate( DATE_ATOM ),
			);
		}

		return array_slice( $rows, -20 );
	}

	/**
	 * @param array<string,mixed> $job Stored job.
	 *
	 * @return list<int>
	 */
	private static function jobFailedIds( array $job ): array {
		$value = isset( $job['failed_ids'] ) && is_array( $job['failed_ids'] ) ? $job['failed_ids'] : array();
		return self::normalizeIds( $value, 500 );
	}

	/**
	 * @param array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} $current Current total.
	 * @param array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} $next    Batch result.
	 *
	 * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int}
	 */
	private static function mergeResults( array $current, array $next ): array {
		return array(
			'selected'     => $current['selected'],
			'scanned'      => $current['scanned'] + $next['scanned'],
			'updated'      => $current['updated'] + $next['updated'],
			'no_candidate' => $current['no_candidate'] + $next['no_candidate'],
			'skipped'      => $current['skipped'] + $next['skipped'],
			'failed'       => $current['failed'] + $next['failed'],
		);
	}

	/**
	 * @param int|null $postId  Post id when available.
	 * @param string   $message Failure message.
	 *
	 * @return array{post_id:int|null,message:string,occurred_at:string}
	 */
	private static function jobError( ?int $postId, string $message ): array {
		return array(
			'post_id'     => $postId,
			'message'     => self::boundedString( $message, 300 ),
			'occurred_at' => gmdate( DATE_ATOM ),
		);
	}

	/**
	 * @param list<array{post_id:int|null,message:string,occurred_at:string}> $current Current errors.
	 * @param list<array{post_id:int|null,message:string,occurred_at:string}> $next    New errors.
	 *
	 * @return list<array{post_id:int|null,message:string,occurred_at:string}>
	 */
	private static function mergeJobErrors( array $current, array $next ): array {
		if ( $next === array() ) {
			return $current;
		}

		return array_slice( array_merge( $current, $next ), -20 );
	}

	/**
	 * @param list<int>                                                       $current Current failed post ids.
	 * @param list<array{post_id:int|null,message:string,occurred_at:string}> $errors  New errors.
	 *
	 * @return list<int>
	 */
	private static function mergeFailedIds( array $current, array $errors ): array {
		foreach ( $errors as $error ) {
			$postId = $error['post_id'];
			if ( $postId !== null && ! in_array( $postId, $current, true ) ) {
				$current[] = $postId;
			}
		}

		return $current;
	}

	/**
	 * @param list<array{post_id:int|null,message:string,occurred_at:string}> $errors Stored errors.
	 */
	private static function latestJobErrorMessage( array $errors ): ?string {
		$error = end( $errors );
		if ( ! is_array( $error ) ) {
			return null;
		}

		$postId  = $error['post_id'];
		$message = $error['message'];
		return $postId !== null ? sprintf( 'Post %d: %s', $postId, $message ) : $message;
	}

	/**
	 * @param Throwable $error Failure.
	 */
	private static function boundedErrorMessage( Throwable $error ): string {
		$message = trim( $error->getMessage() );
		if ( $message === '' ) {
			$message = 'Unexpected ' . $error::class;
		} else {
			$message = $error::class . ': ' . $message;
		}

		return self::boundedString( $message, 300 );
	}

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

	/**
	 * @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 ) ) {
			$int = (int) $value;
			return $int > 0 ? $int : null;
		}

		return null;
	}

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

	/**
	 * @return callable(list<int>): list<array<string,mixed>>
	 */
	private static function defaultPostLoader(): callable {
		return static function ( array $postIds ): array {
			if ( ! function_exists( 'get_posts' ) ) {
				return array();
			}
			$posts = get_posts(
				array(
					'post__in'       => $postIds,
					'post_type'      => 'post',
					'post_status'    => array( 'publish', 'draft', 'pending', 'future', 'private' ),
					'posts_per_page' => count( $postIds ),
					'orderby'        => 'post__in',
				)
			);
			$rows  = array();
			foreach ( $posts as $post ) {
				$rows[] = array(
					'id'      => (int) $post->ID,
					'title'   => (string) $post->post_title,
					'content' => (string) $post->post_content,
				);
			}
			return $rows;
		};
	}

	/**
	 * @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_int( $result ) && $result > 0;
		};
	}

	/**
	 * @return callable(PluginSettings,string,string): list<string>
	 */
	private static function defaultKeywordExtractor(): callable {
		return static function ( PluginSettings $settings, string $title, string $content ): array {
			$provider = new CloudArticleSuggestionProvider( $settings->cloud() );
			$result   = $provider->suggest( $title, $content );
			$keywords = array();
			if ( $result->searchKeywords() !== '' ) {
				$keywords[] = $result->searchKeywords();
			}
			foreach ( $result->candidates() as $candidate ) {
				if ( ! in_array( $candidate, $keywords, true ) ) {
					$keywords[] = $candidate;
				}
			}

			return array_slice( $keywords, 0, 5 );
		};
	}

	/**
	 * @return callable(PluginSettings,string,string,list<string>,list<array<string,mixed>>): array<string,mixed>
	 */
	private static function defaultAffiliateScanEvaluator(): callable {
		return static function ( PluginSettings $settings, string $title, string $content, array $keywords, array $candidates ): array {
			$client  = new CloudAffiliateScanClient();
			$payload = array(
				'title'      => self::boundedString( $title, 300 ),
				'content'    => self::boundedString( $content, 8000 ),
				'locale'     => 'ja-JP',
				'keywords'   => self::boundedStringList( $keywords, 10, 512 ),
				'candidates' => array_slice( $candidates, 0, 60 ),
			);
			return $client->evaluate(
				$settings->cloud(),
				$payload,
				time(),
				bin2hex( random_bytes( 12 ) )
			);
		};
	}
}
