<?php
/**
 * Bulk alt text generation orchestration.
 *
 * @package AIAltTextGenerator
 * @since 2.0.2
 */

namespace AIAltTextGenerator;

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

/**
 * Resolves bulk targets and manages per-user bulk sessions.
 *
 * @since 2.0.2
 */
class BulkGenerator {

	private const SESSION_PREFIX    = 'wpps_ai_alt_bulk_session_';
	private const SUMMARY_PREFIX    = 'wpps_ai_alt_bulk_summary_';
	private const SESSION_TTL       = 6 * HOUR_IN_SECONDS;
	private const MAX_SESSION_TARGETS = 2000;
	private const KEYWORD_MAX       = 6;
	private const USER_META_ACTIVE  = 'wpps_ai_alt_active_bulk';

	/** @var array{seo: list<string>, negative: list<string>}|null */
	private static ?array $active_keywords = null;

	/** @var list<string> */
	private const TARGETS = [ 'missing', 'weak', 'missing_or_weak', 'all' ];

	/**
	 * @param array<string, mixed> $options
	 * @return array{overwrite: bool, only_attached: bool, skip_processed: bool, woo_only: bool, woo_main_only: bool, target: string, keywords: array{seo: list<string>, negative: list<string>}}
	 */
	public static function normalizeOptions( array $options ): array {
		$woo_only      = ! empty( $options['woo_only'] );
		$woo_main_only = ! empty( $options['woo_main_only'] );

		return [
			'overwrite'      => ! empty( $options['overwrite'] ),
			'only_attached'  => ! empty( $options['only_attached'] ),
			'skip_processed' => ! empty( $options['skip_processed'] ),
			'woo_only'       => $woo_only,
			'woo_main_only'  => $woo_main_only,
			'target'         => self::sanitizeTarget( (string) ( $options['target'] ?? 'missing' ) ),
			'keywords'       => self::normalizeKeywords( $options ),
		];
	}

	/**
	 * @param array<string, mixed> $options
	 * @return array{seo: list<string>, negative: list<string>}
	 */
	public static function normalizeKeywords( array $options ): array {
		return [
			'seo'      => self::sanitizeKeywordList( (string) ( $options['seo_keywords'] ?? '' ) ),
			'negative' => self::sanitizeKeywordList( (string) ( $options['negative_keywords'] ?? '' ) ),
		];
	}

	/**
	 * @return list<string>
	 */
	public static function sanitizeKeywordList( string $raw ): array {
		if ( trim( $raw ) === '' ) {
			return [];
		}

		$parts = preg_split( '/\s*,\s*/', sanitize_text_field( $raw ) ) ?: [];
		$seen  = [];
		$out   = [];

		foreach ( $parts as $part ) {
			$part = trim( (string) $part );
			if ( $part === '' ) {
				continue;
			}

			// Cap individual keyword length to avoid prompt stuffing.
			$length = function_exists( 'mb_strlen' ) ? mb_strlen( $part ) : strlen( $part );
			if ( $length > 80 ) {
				$part = function_exists( 'mb_substr' ) ? mb_substr( $part, 0, 80 ) : substr( $part, 0, 80 );
			}

			$key = strtolower( $part );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}

			$seen[ $key ] = true;
			$out[]        = $part;

			if ( count( $out ) >= self::KEYWORD_MAX ) {
				break;
			}
		}

		return $out;
	}

	public static function registerHooks(): void {
		add_filter( 'wpps_ai_alt_text_context', [ self::class, 'mergeBulkKeywords' ], 10, 2 );
	}

	/**
	 * @param array<string, mixed> $context
	 * @return array<string, mixed>
	 */
	public static function mergeBulkKeywords( array $context, int $attachment_id ): array {
		unset( $attachment_id );

		if ( self::$active_keywords === null ) {
			return $context;
		}

		if ( self::$active_keywords['seo'] !== [] ) {
			$context['bulk_keywords'] = implode( ', ', self::$active_keywords['seo'] );
		}

		if ( self::$active_keywords['negative'] !== [] ) {
			$context['negative_keywords'] = implode( ', ', self::$active_keywords['negative'] );
		}

		return $context;
	}

	public static function clearActiveKeywords(): void {
		self::$active_keywords = null;
	}

	/**
	 * @param array{seo: list<string>, negative: list<string>} $keywords
	 */
	public static function applyKeywords( array $keywords ): void {
		self::$active_keywords = [
			'seo'      => is_array( $keywords['seo'] ?? null ) ? array_values( $keywords['seo'] ) : [],
			'negative' => is_array( $keywords['negative'] ?? null ) ? array_values( $keywords['negative'] ) : [],
		];
	}

	public static function sanitizeTarget( string $target ): string {
		return in_array( $target, self::TARGETS, true ) ? $target : 'missing';
	}

	/**
	 * @param array<string, mixed> $options
	 */
	public static function countTargets( array $options ): int {
		global $wpdb;

		[ $sql, $prepare_args ] = self::buildTargetQuery( self::normalizeOptions( $options ) );
		$sql                    = 'SELECT COUNT(DISTINCT p.ID) ' . substr( $sql, strpos( $sql, 'FROM ' ) );

		return (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$prepare_args ) );
	}

	/**
	 * Breakdown for bulk UI hero cards under the current processing options.
	 *
	 * @param array<string, mixed> $options
	 * @return array{total: int, missing: int, with_alt: int, to_process: int}
	 */
	public static function getPreviewStats( array $options ): array {
		$normalized = self::normalizeOptions( $options );

		$pool = $normalized;
		$pool['target']    = 'all';
		$pool['overwrite'] = true;

		global $wpdb;

		[ $sql, $prepare_args ] = self::buildTargetQuery( $pool );
		$count_sql              = 'SELECT COUNT(DISTINCT p.ID) ' . substr( $sql, strpos( $sql, 'FROM ' ) );
		$total                  = (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$prepare_args ) );

		$missing_options = $normalized;
		$missing_options['target']    = 'missing';
		$missing_options['overwrite'] = false;
		$missing                      = self::countTargets( $missing_options );
		$to_process                   = self::countTargets( $normalized );

		return [
			'total'      => $total,
			'missing'    => $missing,
			'with_alt'   => max( 0, $total - $missing ),
			'to_process' => $to_process,
		];
	}

	/**
	 * @param array<string, mixed> $options
	 * @return list<int>
	 */
	public static function getTargetIds( array $options, int $limit = 5000 ): array {
		global $wpdb;

		[ $sql, $prepare_args ] = self::buildTargetQuery( self::normalizeOptions( $options ) );
		$sql                   .= ' ORDER BY p.ID ASC LIMIT %d';
		$prepare_args[]         = $limit;

		$ids = $wpdb->get_col( $wpdb->prepare( $sql, ...$prepare_args ) );

		return array_values( array_filter( array_map( 'intval', is_array( $ids ) ? $ids : [] ) ) );
	}

	/**
	 * @param array{overwrite: bool, only_attached: bool, skip_processed: bool, woo_only: bool, woo_main_only: bool, target: string} $options
	 * @return array{0: string, 1: list<mixed>}
	 */
	private static function buildTargetQuery( array $options ): array {
		global $wpdb;

		$mimes      = AltTextGenerator::getAllowedMimes();
		$mime_place = implode( ',', array_fill( 0, count( $mimes ), '%s' ) );

		$sql = "SELECT DISTINCT p.ID
			FROM {$wpdb->posts} p
			LEFT JOIN {$wpdb->posts} parent ON parent.ID = p.post_parent
			LEFT JOIN {$wpdb->postmeta} am ON am.post_id = p.ID AND am.meta_key = '_wp_attachment_image_alt'
			LEFT JOIN {$wpdb->postmeta} dm ON dm.post_id = p.ID AND dm.meta_key = %s AND dm.meta_value = '1'";

		$prepare_args = [ AttachmentMeta::META_DECORATIVE ];
		$excluded     = ImageFilter::excludedPostTypes();

		if ( $options['skip_processed'] ) {
			$sql .= "
			LEFT JOIN {$wpdb->postmeta} gp ON gp.post_id = p.ID AND gp.meta_key = %s";
			$prepare_args[] = AttachmentMeta::META_GENERATED_AT;
		}

		$sql .= "
			WHERE p.post_type = %s
			AND p.post_status = %s
			AND p.post_mime_type IN ($mime_place)
			AND (dm.post_id IS NULL OR dm.meta_value != '1')";

		$prepare_args = array_merge( $prepare_args, [ 'attachment', 'inherit' ], $mimes );

		if ( $options['only_attached'] ) {
			$sql .= ' AND p.post_parent > 0';
		}

		if ( $excluded !== [] ) {
			$type_place = implode( ',', array_fill( 0, count( $excluded ), '%s' ) );
			$sql       .= " AND (p.post_parent = 0 OR parent.post_type IS NULL OR parent.post_type NOT IN ($type_place))";
			$prepare_args = array_merge( $prepare_args, $excluded );
		}

		$sql .= self::targetSql( $options );

		if ( $options['skip_processed'] ) {
			$sql .= ' AND gp.post_id IS NULL';
		}

		$sql .= self::wooFilterSql( $options );

		return [ $sql, $prepare_args ];
	}

	/**
	 * @param array{woo_only: bool, woo_main_only: bool} $options
	 */
	private static function wooFilterSql( array $options ): string {
		if ( ! class_exists( 'WooCommerce' ) ) {
			return '';
		}

		if ( empty( $options['woo_only'] ) && empty( $options['woo_main_only'] ) ) {
			return '';
		}

		global $wpdb;

		if ( ! empty( $options['woo_main_only'] ) ) {
			return "
			AND EXISTS (
				SELECT 1 FROM {$wpdb->postmeta} woo_thumb
				INNER JOIN {$wpdb->posts} woo_post ON woo_post.ID = woo_thumb.post_id
				WHERE woo_thumb.meta_key = '_thumbnail_id'
				AND woo_thumb.meta_value = CAST(p.ID AS CHAR)
				AND woo_post.post_type IN ('product', 'product_variation')
			)";
		}

		return "
			AND (
				EXISTS (
					SELECT 1 FROM {$wpdb->postmeta} woo_thumb
					INNER JOIN {$wpdb->posts} woo_post ON woo_post.ID = woo_thumb.post_id
					WHERE woo_thumb.meta_key = '_thumbnail_id'
					AND woo_thumb.meta_value = CAST(p.ID AS CHAR)
					AND woo_post.post_type IN ('product', 'product_variation')
				)
				OR EXISTS (
					SELECT 1 FROM {$wpdb->postmeta} woo_gallery
					INNER JOIN {$wpdb->posts} woo_product ON woo_product.ID = woo_gallery.post_id
					WHERE woo_gallery.meta_key = '_product_image_gallery'
					AND FIND_IN_SET(CAST(p.ID AS CHAR), woo_gallery.meta_value) > 0
					AND woo_product.post_type = 'product'
				)
			)";
	}

	/**
	 * @param array{overwrite: bool, target: string} $options
	 */
	private static function targetSql( array $options ): string {
		if ( ! empty( $options['overwrite'] ) ) {
			return '';
		}

		$weak = self::weakAltSql();

		switch ( $options['target'] ) {
			case 'weak':
				return " AND am.meta_value IS NOT NULL AND am.meta_value != '' AND ({$weak})";
			case 'missing_or_weak':
				return " AND ((am.meta_value IS NULL OR am.meta_value = '') OR ({$weak}))";
			case 'all':
				return '';
			case 'missing':
			default:
				return " AND (am.meta_value IS NULL OR am.meta_value = '')";
		}
	}

	private static function weakAltSql(): string {
		return QualityScorer::weakAltSqlClause( 'am.meta_value' );
	}

	/**
	 * Whether generation should force overwrite for the selected target.
	 *
	 * @param array{overwrite: bool, target: string} $options
	 */
	public static function shouldForceOverwrite( array $options ): bool {
		if ( ! empty( $options['overwrite'] ) ) {
			return true;
		}

		return in_array( $options['target'], [ 'weak', 'all' ], true );
	}

	public static function setActiveBulk( string $mode, string $id ): void {
		$user_id = get_current_user_id();
		if ( $user_id <= 0 ) {
			return;
		}

		update_user_meta(
			$user_id,
			self::USER_META_ACTIVE,
			[
				'mode'    => sanitize_key( $mode ),
				'id'      => sanitize_key( $id ),
				'updated' => time(),
			]
		);
	}

	public static function clearActiveBulk(): void {
		$user_id = get_current_user_id();
		if ( $user_id > 0 ) {
			delete_user_meta( $user_id, self::USER_META_ACTIVE );
		}
	}

	/**
	 * @return array{mode: string, id: string, updated: int}|null
	 */
	public static function getActiveBulk(): ?array {
		$user_id = get_current_user_id();
		if ( $user_id <= 0 ) {
			return null;
		}

		$raw = get_user_meta( $user_id, self::USER_META_ACTIVE, true );
		if ( ! is_array( $raw ) || empty( $raw['mode'] ) ) {
			return null;
		}

		return [
			'mode'    => sanitize_key( (string) $raw['mode'] ),
			'id'      => sanitize_key( (string) ( $raw['id'] ?? '' ) ),
			'updated' => (int) ( $raw['updated'] ?? 0 ),
		];
	}

	/**
	 * @return array<string, mixed>|null
	 */
	public static function getProgressStatus(): ?array {
		$active = self::getActiveBulk();

		if ( $active !== null && $active['mode'] === 'queue' ) {
			self::clearActiveBulk();
		}

		if ( $active !== null && $active['mode'] === 'session' && $active['id'] !== '' ) {
			$status = self::getSessionStatus( $active['id'] );
			if ( $status !== null ) {
				return $status;
			}
		}

		return null;
	}

	/**
	 * @return array<string, mixed>|null
	 */
	public static function getSessionStatus( string $session ): ?array {
		$key   = self::sessionKey( $session );
		$state = self::loadState( $key );

		if ( is_array( $state ) && ! empty( $state['ids'] ) && is_array( $state['ids'] ) ) {
			$total  = count( $state['ids'] );
			$offset = (int) ( $state['offset'] ?? 0 );

			return [
				'mode'         => 'session',
				'session'      => $session,
				'done'         => $offset >= $total,
				'total'        => $total,
				'current'      => $offset,
				'processed'    => (int) ( $state['processed'] ?? 0 ),
				'failed'       => (int) ( $state['failed'] ?? 0 ),
				'skipped'      => (int) ( $state['skipped'] ?? 0 ),
				'failed_items' => is_array( $state['failed_items'] ?? null ) ? $state['failed_items'] : [],
			];
		}

		$summary = self::loadState( self::summaryKey( $session ) );
		if ( ! is_array( $summary ) ) {
			return null;
		}

		$failed_items = is_array( $summary['failed_items'] ?? null ) ? $summary['failed_items'] : [];

		return [
			'mode'         => 'session',
			'session'      => $session,
			'done'         => true,
			'total'        => (int) ( $summary['total'] ?? count( $failed_items ) ),
			'current'      => (int) ( $summary['total'] ?? count( $failed_items ) ),
			'processed'    => (int) ( $summary['processed'] ?? 0 ),
			'failed'       => count( $failed_items ),
			'skipped'      => (int) ( $summary['skipped'] ?? 0 ),
			'failed_items' => $failed_items,
		];
	}

	/**
	 * @return array{session: string, total: int, batch_size: int}|null
	 */
	public static function retryFailedSession( string $session ): ?array {
		$summary = self::loadState( self::summaryKey( $session ) );
		if ( ! is_array( $summary ) ) {
			return null;
		}

		$failed_items = is_array( $summary['failed_items'] ?? null ) ? $summary['failed_items'] : [];
		$ids          = array_values(
			array_filter(
				array_map(
					static fn( $item ): int => is_array( $item ) ? (int) ( $item['id'] ?? 0 ) : 0,
					$failed_items
				)
			)
		);

		if ( $ids === [] ) {
			return null;
		}

		self::deleteState( self::summaryKey( $session ) );

		return self::startSessionFromIds(
			$ids,
			! empty( $summary['overwrite'] ),
			is_array( $summary['keywords'] ?? null ) ? $summary['keywords'] : [ 'seo' => [], 'negative' => [] ]
		);
	}

	/**
	 * @param list<int> $ids
	 * @param array{seo: list<string>, negative: list<string>} $keywords
	 * @return array{session: string, total: int, batch_size: int, match_total: int, capped: bool}
	 */
	private static function startSessionFromIds( array $ids, bool $overwrite, array $keywords ): array {
		$settings   = Settings::get();
		$batch_size = Settings::clampBatchSize( (int) ( $settings['batch_size'] ?? 5 ) );
		$session    = wp_generate_password( 16, false, false );

		self::storeState(
			self::sessionKey( $session ),
			[
				'ids'          => $ids,
				'offset'       => 0,
				'overwrite'    => $overwrite,
				'batch_size'   => $batch_size,
				'processed'    => 0,
				'failed'       => 0,
				'skipped'      => 0,
				'failed_items' => [],
				'started_at'   => time(),
				'match_total'  => count( $ids ),
				'capped'       => false,
				'keywords'     => $keywords,
			],
			self::SESSION_TTL
		);

		return [
			'session'     => $session,
			'total'       => count( $ids ),
			'match_total' => count( $ids ),
			'capped'      => false,
			'batch_size'  => $batch_size,
		];
	}

	/**
	 * @param array<string, mixed> $options
	 * @return array{session: string, total: int, batch_size: int}
	 */
	public static function startSession( array $options ): array {
		$normalized = self::normalizeOptions( $options );
		$match_total = self::countTargets( $normalized );
		$ids         = self::getTargetIds( $normalized, self::MAX_SESSION_TARGETS );
		$settings    = Settings::get();
		$batch_size  = Settings::clampBatchSize( (int) ( $settings['batch_size'] ?? 5 ) );

		$session = wp_generate_password( 16, false, false );
		$key     = self::sessionKey( $session );

		self::storeState(
			$key,
			[
				'ids'          => $ids,
				'offset'       => 0,
				'overwrite'    => self::shouldForceOverwrite( $normalized ),
				'batch_size'   => $batch_size,
				'processed'    => 0,
				'failed'       => 0,
				'skipped'      => 0,
				'failed_items' => [],
				'started_at'   => time(),
				'match_total'  => $match_total,
				'capped'       => $match_total > count( $ids ),
				'keywords'     => $normalized['keywords'],
			],
			self::SESSION_TTL
		);

		return [
			'session'     => $session,
			'total'       => count( $ids ),
			'match_total' => $match_total,
			'capped'      => $match_total > count( $ids ),
			'batch_size'  => $batch_size,
		];
	}

	/**
	 * Process the next batch in a bulk session.
	 *
	 * @param bool $skip_current Mark the current image failed and advance (after host HTTP 500s).
	 * @return array<string, mixed>
	 */
	public static function processNext( string $session, bool $skip_current = false ): array {
		$key   = self::sessionKey( $session );
		$state = self::loadState( $key );

		if ( ! is_array( $state ) || empty( $state['ids'] ) || ! is_array( $state['ids'] ) ) {
			self::clearActiveKeywords();
			return [
				'done'    => true,
				'message' => __( 'Bulk session expired or not found. Start the run again. If this repeats with object cache enabled, purge the object cache once and retry.', 'wp-powersuite' ),
			];
		}

		$offset = (int) ( $state['offset'] ?? 0 );
		$ids    = $state['ids'];
		$total  = count( $ids );
		// Keep AJAX steps to 1 image. Multi-image steps routinely hit LiteSpeed/Hostinger
		// 500s under object cache; queue/cron still uses Settings batch_size.
		$batch_size = 1;
		$overwrite  = ! empty( $state['overwrite'] );
		$keywords   = is_array( $state['keywords'] ?? null ) ? $state['keywords'] : [ 'seo' => [], 'negative' => [] ];
		self::$active_keywords = [
			'seo'      => is_array( $keywords['seo'] ?? null ) ? array_values( $keywords['seo'] ) : [],
			'negative' => is_array( $keywords['negative'] ?? null ) ? array_values( $keywords['negative'] ) : [],
		];

		if ( $offset >= $total ) {
			self::clearActiveKeywords();
			self::deleteState( $key );
			Scanner::invalidateCache();

			return [
				'done'      => true,
				'total'     => $total,
				'processed' => (int) ( $state['processed'] ?? 0 ),
				'failed'    => (int) ( $state['failed'] ?? 0 ),
				'skipped'   => (int) ( $state['skipped'] ?? 0 ),
			];
		}

		$items = [];

		if ( $skip_current ) {
			$attachment_id = (int) $ids[ $offset ];
			$result        = self::buildProcessResult(
				$attachment_id,
				[
					'status'    => 'failed',
					'message'   => __( 'Skipped after repeated server timeouts. Retry failed images when the run finishes.', 'wp-powersuite' ),
					'code'      => GenerationError::CODE_SERVER,
					'retryable' => true,
				]
			);
			++$state['failed'];
			if ( ! isset( $state['failed_items'] ) || ! is_array( $state['failed_items'] ) ) {
				$state['failed_items'] = [];
			}
			$state['failed_items'][] = [
				'id'       => $attachment_id,
				'message'  => (string) $result['message'],
				'code'     => (string) $result['code'],
				'filename' => (string) $result['filename'],
			];
			$items[] = $result;
			++$offset;
		} else {
			for ( $i = 0; $i < $batch_size && $offset < $total; ++$i ) {
				$attachment_id = (int) $ids[ $offset ];
				$result        = self::processAttachment( $attachment_id, $overwrite );

				if ( $result['status'] === 'success' ) {
					++$state['processed'];
				} elseif ( $result['status'] === 'skipped' ) {
					++$state['skipped'];
				} else {
					++$state['failed'];
					if ( ! isset( $state['failed_items'] ) || ! is_array( $state['failed_items'] ) ) {
						$state['failed_items'] = [];
					}
					$state['failed_items'][] = [
						'id'       => $attachment_id,
						'message'  => (string) ( $result['message'] ?? '' ),
						'code'     => (string) ( $result['code'] ?? '' ),
						'filename' => (string) ( $result['filename'] ?? '' ),
					];
				}

				$items[] = $result;
				++$offset;
			}
		}

		$state['offset'] = $offset;
		self::storeState( $key, $state, self::SESSION_TTL );

		$done = $offset >= $total;
		if ( $done ) {
			$failed_items = is_array( $state['failed_items'] ?? null ) ? $state['failed_items'] : [];
			self::storeState(
				self::summaryKey( $session ),
				[
					'total'        => $total,
					'processed'    => (int) ( $state['processed'] ?? 0 ),
					'failed'       => (int) ( $state['failed'] ?? 0 ),
					'skipped'      => (int) ( $state['skipped'] ?? 0 ),
					'failed_items' => $failed_items,
					'overwrite'    => $overwrite,
					'keywords'     => $keywords,
				],
				DAY_IN_SECONDS
			);
			self::clearActiveBulk();
			self::deleteState( $key );
			Scanner::invalidateCache();
			self::clearActiveKeywords();
		}

		$last_item = $items[ count( $items ) - 1 ] ?? null;

		return [
			'done'         => $done,
			'total'        => $total,
			'current'      => $offset,
			'processed'    => (int) ( $state['processed'] ?? 0 ),
			'failed'       => (int) ( $state['failed'] ?? 0 ),
			'skipped'      => (int) ( $state['skipped'] ?? 0 ),
			'batch_size'   => $batch_size,
			'items'        => $items,
			'item'         => $last_item,
			'failed_items' => is_array( $state['failed_items'] ?? null ) ? $state['failed_items'] : [],
			'mode'         => 'session',
			'session'      => $session,
		];
	}

	/**
	 * @return array{id: int, filename: string, thumb_url: string, edit_url: string, status: string, alt_text: string, message: string}
	 */
	public static function buildProcessResult( int $attachment_id, array $outcome ): array {
		$thumb_url = wp_get_attachment_image_url( $attachment_id, 'thumbnail' );
		$edit_url  = get_edit_post_link( $attachment_id, 'raw' );

		return [
			'id'                => $attachment_id,
			'filename'          => basename( (string) get_attached_file( $attachment_id ) ),
			'thumb_url'         => is_string( $thumb_url ) ? $thumb_url : '',
			'edit_url'          => is_string( $edit_url ) ? $edit_url : '',
			'status'            => (string) ( $outcome['status'] ?? 'failed' ),
			'alt_text'          => (string) ( $outcome['alt_text'] ?? '' ),
			'message'           => (string) ( $outcome['message'] ?? '' ),
			'code'              => (string) ( $outcome['code'] ?? '' ),
			'retryable'         => ! empty( $outcome['retryable'] ),
			'keywords_applied'  => is_array( $outcome['keywords_applied'] ?? null ) ? array_values( $outcome['keywords_applied'] ) : [],
			'keywords_injected' => ! empty( $outcome['keywords_injected'] ),
		];
	}

	/**
	 * @return array{id: int, filename: string, status: string, alt_text: string, message: string}
	 */
	private static function processAttachment( int $attachment_id, bool $overwrite ): array {
		$result = self::buildProcessResult(
			$attachment_id,
			[
				'status'  => 'failed',
				'message' => '',
			]
		);

		if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
			$result['message'] = __( 'You do not have permission to edit this attachment.', 'wp-powersuite' );
			$result['code']    = GenerationError::CODE_PERMISSION;
			return $result;
		}

		// Bulk sessions already throttle via batch_size; do not apply the interactive AJAX rate limit.
		// Keep each AJAX step short so LiteSpeed/Hostinger cannot hard-kill the request with HTTP 500.
		try {
			$outcome = GenerationService::generateForAttachment(
				$attachment_id,
				$overwrite,
				[
					'bulk'            => true,
					'skip_rewrite'    => true,
					'skip_metadata'   => true,
					'skip_duplicates' => true,
					'timeout'         => 18,
					'force_jpeg'      => true,
				]
			);
		} catch ( \Throwable $e ) {
			Diagnostics::recordBulkError(
				[
					'code'          => GenerationError::CODE_INTERNAL,
					'message'       => $e->getMessage(),
					'attachment_id' => $attachment_id,
				]
			);

			return self::buildProcessResult(
				$attachment_id,
				[
					'status'    => 'failed',
					'message'   => __( 'Server error while processing this image. It will be listed as failed so the rest of the batch can continue.', 'wp-powersuite' ),
					'code'      => GenerationError::CODE_SERVER,
					'retryable' => true,
				]
			);
		}

		if ( ( $outcome['status'] ?? '' ) === 'failed' ) {
			Diagnostics::recordBulkError(
				[
					'code'          => (string) ( $outcome['code'] ?? GenerationError::CODE_INTERNAL ),
					'message'       => (string) ( $outcome['message'] ?? '' ),
					'attachment_id' => $attachment_id,
				]
			);
			Diagnostics::debug(
				'bulk_attachment_failed',
				[
					'attachment_id' => $attachment_id,
					'code'          => (string) ( $outcome['code'] ?? '' ),
					'message'       => (string) ( $outcome['message'] ?? '' ),
				]
			);
		}

		return self::buildProcessResult( $attachment_id, $outcome );
	}

	/**
	 * Process multiple queue batches in one request (status poll or cron).
	 */
	public static function advanceQueueWhilePolling( int $max_seconds = 20 ): int {
		$deadline = microtime( true ) + max( 5, $max_seconds );
		$batches  = 0;

		while ( microtime( true ) < $deadline ) {
			$state = Queue::getState();
			if ( ( $state['status'] ?? 'idle' ) !== 'running' || empty( $state['items'] ) ) {
				break;
			}

			if ( Usage::isLimitReached() ) {
				break;
			}

			Queue::processBatch();
			++$batches;
		}

		if ( $batches > 0 && function_exists( 'spawn_cron' ) ) {
			spawn_cron();
		}

		return $batches;
	}

	public static function cancelSession( string $session ): void {
		self::deleteState( self::sessionKey( $session ) );
		self::deleteState( self::summaryKey( $session ) );
		self::clearActiveKeywords();
	}

	private static function summaryKey( string $session ): string {
		return self::SUMMARY_PREFIX . get_current_user_id() . '_' . sanitize_key( $session );
	}

	private static function sessionKey( string $session ): string {
		return self::SESSION_PREFIX . get_current_user_id() . '_' . sanitize_key( $session );
	}

	/**
	 * Durable job-state helpers: DB option + disk file (object-cache safe).
	 *
	 * @param mixed $value
	 */
	private static function storeState( string $key, $value, int $ttl ): void {
		if ( is_array( $value ) ) {
			$value['updated_at'] = time();
			$value['expires_at'] = time() + max( 60, $ttl );
			self::writeStateFile( $key, $value );
		}

		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::setJobState( $key, $value, $ttl );
			return;
		}
		set_transient( $key, $value, $ttl );
	}

	/**
	 * @return mixed|false
	 */
	private static function loadState( string $key ) {
		$from_disk = self::readStateFile( $key );

		$from_store = class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class )
			? \WP_PowerSuite\Core\LongAjaxResponse::getJobState( $key )
			: get_transient( $key );

		if ( is_array( $from_disk ) && is_array( $from_store ) ) {
			$disk_t = (int) ( $from_disk['updated_at'] ?? $from_disk['offset'] ?? 0 );
			$mem_t  = (int) ( $from_store['updated_at'] ?? $from_store['offset'] ?? 0 );

			return $disk_t >= $mem_t ? $from_disk : $from_store;
		}

		if ( is_array( $from_disk ) ) {
			return $from_disk;
		}

		return ( false !== $from_store && null !== $from_store ) ? $from_store : false;
	}

	private static function deleteState( string $key ): void {
		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::deleteJobState( $key );
		} else {
			delete_transient( $key );
		}
		self::deleteStateFile( $key );
	}

	private static function stateFilePath( string $key ): string {
		$uploads = wp_upload_dir();
		$base    = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
		if ( $base === '' || ! empty( $uploads['error'] ) ) {
			return '';
		}

		$dir = trailingslashit( $base ) . 'wp-powersuite/ai-alt-jobs';
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( trailingslashit( $dir ) . 'index.php', "<?php\n// Silence is golden.\n" );
		}

		return trailingslashit( $dir ) . md5( $key ) . '.json';
	}

	/**
	 * @param array<string, mixed> $value
	 */
	private static function writeStateFile( string $key, array $value ): void {
		$path = self::stateFilePath( $key );
		if ( $path === '' ) {
			return;
		}
		$json = wp_json_encode( $value );
		if ( ! is_string( $json ) || $json === '' ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		@file_put_contents( $path, $json, LOCK_EX );
	}

	/**
	 * @return array<string, mixed>|null
	 */
	private static function readStateFile( string $key ): ?array {
		$path = self::stateFilePath( $key );
		if ( $path === '' || ! is_readable( $path ) ) {
			return null;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$raw = file_get_contents( $path );
		if ( ! is_string( $raw ) || $raw === '' ) {
			return null;
		}
		$decoded = json_decode( $raw, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}
		$expires = (int) ( $decoded['expires_at'] ?? 0 );
		if ( $expires > 0 && $expires < time() ) {
			self::deleteStateFile( $key );

			return null;
		}

		return $decoded;
	}

	private static function deleteStateFile( string $key ): void {
		$path = self::stateFilePath( $key );
		if ( $path !== '' && is_file( $path ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $path );
		}
	}
}
