<?php
/**
 * AI Alt Text Generator Module
 *
 * @package AIAltTextGenerator
 * @since 1.0.0
 */

namespace AIAltTextGenerator;

use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\Contracts\DataLocationsInterface;

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

require_once __DIR__ . '/includes/class-module-loader.php';
require_once __DIR__ . '/includes/class-capabilities.php';
require_once __DIR__ . '/includes/class-feature-gate.php';
require_once __DIR__ . '/includes/class-network-settings.php';
require_once __DIR__ . '/includes/class-settings.php';
require_once __DIR__ . '/includes/class-alt-text-output.php';
require_once __DIR__ . '/includes/class-quality-scorer.php';
require_once __DIR__ . '/includes/class-local-suggester.php';
require_once __DIR__ . '/includes/class-attachment-meta.php';
require_once __DIR__ . '/includes/class-scanner.php';
require_once __DIR__ . '/includes/class-logger.php';
require_once __DIR__ . '/includes/class-usage.php';
require_once __DIR__ . '/includes/providers/interface-provider.php';
require_once __DIR__ . '/includes/providers/class-openai-provider.php';
require_once __DIR__ . '/includes/class-image-filter.php';
require_once __DIR__ . '/includes/class-generation-service.php';
require_once __DIR__ . '/includes/class-queue.php';
require_once __DIR__ . '/includes/class-attachment-status.php';
require_once __DIR__ . '/includes/class-duplicate-detector.php';
require_once __DIR__ . '/includes/class-content-sync.php';
require_once __DIR__ . '/includes/providers/class-vision-helper.php';
wp_powersuite_require_freemium_file( 'ai-alt-text-generator', 'includes/providers/class-gemini-provider.php' );
wp_powersuite_require_freemium_file( 'ai-alt-text-generator', 'includes/providers/class-claude-provider.php' );
wp_powersuite_require_freemium_file( 'ai-alt-text-generator', 'includes/class-premium-openai-models.php' );
wp_powersuite_require_freemium_file( 'ai-alt-text-generator', 'includes/class-premium-duplicate-actions.php' );
wp_powersuite_require_freemium_file( 'ai-alt-text-generator', 'includes/class-premium-full-scan.php' );
wp_powersuite_require_freemium_file( 'ai-alt-text-generator', 'includes/class-premium-alt-rewriter.php' );
require_once __DIR__ . '/includes/class-provider-registry.php';
require_once __DIR__ . '/includes/class-attachment-usage.php';
require_once __DIR__ . '/includes/class-bulk-actions.php';
require_once __DIR__ . '/includes/class-bulk-generator.php';
require_once __DIR__ . '/includes/class-performance.php';
require_once __DIR__ . '/includes/class-post-refresh.php';
require_once __DIR__ . '/includes/class-alt-text-generator.php';
require_once __DIR__ . '/includes/class-context-helper.php';
require_once __DIR__ . '/includes/class-alt-rewriter.php';
require_once __DIR__ . '/includes/class-generation-error.php';
require_once __DIR__ . '/includes/class-diagnostics.php';
require_once __DIR__ . '/includes/class-text.php';
require_once __DIR__ . '/includes/class-models.php';

/**
 * AI Alt Text Generator Module Class
 *
 * @since 1.0.0
 */
class Module extends AbstractModule implements DataLocationsInterface {

	private const MODULE_ID      = 'ai-alt-text-generator';
	private const MODULE_VERSION = '2.5.2';
	private const NONCE_ACTION     = 'wpps_ai_alt_text';
	/** Interactive single-generation safety valve (not applied to bulk sessions/queue). */
	private const RATE_LIMIT_MAX    = 60;
	private const RATE_LIMIT_WINDOW = 60;
	private const MAX_BULK_IDS     = 500;
	private const MAX_SESSION_TARGETS = 2000;
	public const DASHBOARD_SLUG    = 'powersuite-ai-alt-text';

	public static function nonceAction(): string {
		return self::NONCE_ACTION;
	}

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'AI Alt Text Generator', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Generate accessible, SEO-friendly alt text with AI. Includes a dashboard, review queue, background processing, multi-provider support, REST/WP-CLI automation, and Pro extras like extra providers and scheduled scans.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'ajax', 'rest', 'cron' ];
	}

	public function getDependencies(): array {
		return [];
	}

	/**
	 * @return array{options: list<string>, tables: list<string>}
	 */
	public function getDataLocations(): array {
		return [
			'options' => [
				Settings::USAGE_OPTION,
				Settings::QUEUE_OPTION,
				Diagnostics::LAST_ERROR_OPTION,
			],
			'tables'  => [ 'wpps_ai_alt_log' ],
		];
	}

	protected function registerHooks(): void {
		add_action( 'admin_init', [ $this, 'maybeRegisterCapabilities' ], 1 );
		add_action( 'init', [ $this, 'maybeCreateTable' ], 20 );
		add_action( 'init', [ Logger::class, 'maybeSchedulePurge' ], 25 );
		add_action( Logger::CRON_HOOK, [ Logger::class, 'purgeExpired' ] );
		BulkGenerator::registerHooks();
		add_action( 'save_post', [ $this, 'onPostSaved' ], 20, 1 );
		add_action( 'wp_powersuite_module_settings_saved', [ $this, 'onSettingsSaved' ], 10, 2 );

		add_action( 'add_meta_boxes_attachment', [ $this, 'addAltTextMetaBox' ] );
		add_filter( 'bulk_actions-upload', [ $this, 'addBulkAction' ] );
		add_filter( 'handle_bulk_actions-upload', [ $this, 'handleBulkAction' ], 10, 3 );
		add_action( 'load-edit.php', [ $this, 'registerPostBulkActions' ] );
		add_action( 'load-upload.php', [ $this, 'maybeShowBulkNotice' ] );
		add_filter( 'wp_update_attachment_metadata', [ $this, 'maybeScheduleAutoGenerateAfterMetadata' ], 999, 2 );
		add_filter( 'wp_prepare_attachment_for_js', [ $this, 'prepareAttachmentForJs' ], 10, 3 );
		add_action( 'admin_init', [ $this, 'maybeProcessQueueOnAdminLoad' ], 5 );

		add_action( $this->adminMenuHook(), [ $this, 'registerMenu' ], 20 );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAdminAssets' ] );
		add_action( 'admin_init', [ $this, 'registerIntegrations' ], 20 );

		add_action( 'wp_ajax_wpps_ai_alt_generate', [ $this, 'ajaxGenerate' ] );
		add_action( 'wp_ajax_wpps_ai_alt_save', [ $this, 'ajaxSave' ] );
		add_action( 'wp_ajax_wpps_ai_alt_bulk', [ $this, 'ajaxBulk' ] );
		add_action( 'wp_ajax_wpps_ai_alt_bulk_start', [ $this, 'ajaxBulkStart' ] );
		add_action( 'wp_ajax_wpps_ai_alt_bulk_preview', [ $this, 'ajaxBulkPreview' ] );
		add_action( 'wp_ajax_wpps_ai_alt_dashboard_stats', [ $this, 'ajaxDashboardStats' ] );
		add_action( 'wp_ajax_wpps_ai_alt_load_tab', [ $this, 'ajaxLoadTab' ] );
		add_action( 'wp_ajax_wpps_ai_alt_bulk_step', [ $this, 'ajaxBulkStep' ] );
		add_action( 'wp_ajax_wpps_ai_alt_bulk_status', [ $this, 'ajaxBulkStatus' ] );
		add_action( 'wp_ajax_wpps_ai_alt_bulk_retry_failed', [ $this, 'ajaxBulkRetryFailed' ] );
		add_action( 'wp_ajax_wpps_ai_alt_bulk_cancel', [ $this, 'ajaxBulkCancel' ] );
		add_action( 'wp_ajax_wpps_ai_alt_local_suggest', [ $this, 'ajaxLocalSuggest' ] );
		add_action( 'wp_ajax_wpps_ai_alt_mark_decorative', [ $this, 'ajaxMarkDecorative' ] );
		add_action( 'wp_ajax_wpps_ai_alt_unmark_decorative', [ $this, 'ajaxUnmarkDecorative' ] );
		add_action( 'wp_ajax_wpps_ai_alt_reject', [ $this, 'ajaxReject' ] );

		add_action( 'rest_api_init', [ $this, 'registerRestRoutes' ] );
		add_action( Queue::CRON_HOOK, [ $this, 'processQueue' ] );
		add_action( 'wpps_ai_alt_content_scan_batch', [ $this, 'processContentScanBatch' ] );
		add_action( 'wp_ajax_wpps_ai_alt_test_connection', [ $this, 'ajaxTestConnection' ] );
		add_action( 'wp_ajax_wpps_ai_alt_regenerate_duplicates', [ $this, 'ajaxRegenerateDuplicates' ] );
		add_action( 'admin_post_wpps_ai_alt_import', [ $this, 'handleImport' ] );
		add_action( 'admin_post_wpps_ai_alt_export', [ $this, 'handleExport' ] );
		add_action( 'admin_post_wpps_ai_alt_save_settings', [ $this, 'handleSaveSettings' ] );

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			add_action( 'cli_init', [ $this, 'registerCliCommand' ] );
		}
	}

	public function maybeRegisterCapabilities(): void {
		$stored = (string) get_option( 'wpps_ai_alt_caps_version', '' );
		if ( $stored === self::MODULE_VERSION ) {
			return;
		}

		Capabilities::register();
		update_option( 'wpps_ai_alt_caps_version', self::MODULE_VERSION, false );
	}

	public function registerIntegrations(): void {
		if ( ! is_admin() ) {
			return;
		}

		ModuleLoader::integrations();

		if ( Capabilities::canView() && class_exists( MediaColumns::class ) ) {
			( new MediaColumns() )->register();
		}
		if ( class_exists( BlockEditor::class ) ) {
			( new BlockEditor() )->register();
		}
		if ( class_exists( WooCommerceIntegration::class ) ) {
			( new WooCommerceIntegration() )->register();
		}
		if ( class_exists( ElementorIntegration::class ) ) {
			( new ElementorIntegration() )->register();
		}
		if ( class_exists( Scheduler::class ) ) {
			( new Scheduler() )->register();
		}
	}

	public function registerCliCommand(): void {
		ModuleLoader::wpCli();
		if ( class_exists( WP_CLI_Command::class ) ) {
			\WP_CLI::add_command( 'alttext', __NAMESPACE__ . '\\WP_CLI_Command' );
		}
	}

	public function maybeCreateTable(): void {
		if ( ! is_admin() && ! wp_doing_cron() ) {
			return;
		}

		Logger::maybeCreateTable();
	}

	public function onPostSaved( int $post_id ): void {
		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return;
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return;
		}

		$skip_types = [
			'attachment',
			'revision',
			'nav_menu_item',
			'custom_css',
			'customize_changeset',
			'oembed_cache',
			'wp_block',
			'wp_template',
			'wp_template_part',
			'wp_navigation',
		];

		if ( in_array( $post->post_type, $skip_types, true ) ) {
			return;
		}

		AttachmentUsage::invalidateForPost( $post_id );

		if ( $post->post_content === '' && get_post_thumbnail_id( $post_id ) <= 0 ) {
			return;
		}

		ModuleLoader::contentScanner();
		if ( class_exists( ContentScanner::class ) ) {
			ContentScanner::invalidateCache();
		}
	}

	public function onSettingsSaved( string $module_id, array $settings ): void {
		if ( $module_id !== self::MODULE_ID ) {
			return;
		}

		Settings::flushCache();
		Capabilities::register();
		update_option( 'wpps_ai_alt_caps_version', self::MODULE_VERSION, false );
		delete_transient( Logger::PURGE_TRANSIENT );
		Logger::purgeExpired();
	}

	public function registerMenu(): void {
		$this->registerPowerSuiteSubmenu(
			__( 'AI Alt Text', 'wp-powersuite' ),
			__( 'AI Alt Text', 'wp-powersuite' ),
			self::DASHBOARD_SLUG,
			[ $this, 'renderDashboard' ]
		);
	}

	public function renderDashboard(): void {
		if ( ! Capabilities::canView() ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}

		ModuleLoader::adminActions();
		AdminActions::maybeProcess();
		include __DIR__ . '/views/dashboard.php';
	}

	/**
	 * Resolve and authorize a dashboard tab slug.
	 */
	public static function resolveTab( string $tab ): string {
		$tab = sanitize_key( $tab );
		if ( $tab === '' ) {
			return 'dashboard';
		}

		if ( $tab === 'settings' && ! Capabilities::canManage() ) {
			return 'dashboard';
		}

		if ( in_array( $tab, [ 'bulk', 'review' ], true ) && ! Capabilities::canGenerate() ) {
			return 'dashboard';
		}
		if ( $tab === 'history' && ! Capabilities::canView() ) {
			return 'dashboard';
		}

		$allowed = [ 'dashboard', 'bulk', 'review', 'history', 'settings' ];

		return in_array( $tab, $allowed, true ) ? $tab : 'dashboard';
	}

	/**
	 * Render a single tab panel as HTML.
	 */
	public static function renderTabContent( string $tab ): string {
		$tab = self::resolveTab( $tab );
		ModuleLoader::tab( $tab );

		$partials = [
			'bulk'      => 'bulk-tab.php',
			'review'    => 'review-tab.php',
			'history'   => 'history-tab.php',
			'settings'  => 'settings-tab.php',
			'dashboard' => 'dashboard-tab.php',
		];

		$partial = $partials[ $tab ] ?? $partials['dashboard'];
		$path    = __DIR__ . '/views/partials/' . $partial;

		if ( ! is_readable( $path ) ) {
			return '';
		}

		ob_start();
		include $path;

		return (string) ob_get_clean();
	}

	public function ajaxLoadTab(): void {
		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid security token.', 'wp-powersuite' ) ] );
		}

		if ( ! Capabilities::canView() ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		$requested = isset( $_POST['tab'] ) ? sanitize_key( wp_unslash( (string) $_POST['tab'] ) ) : 'dashboard';
		$tab       = self::resolveTab( $requested );
		$summary   = $tab === 'dashboard' ? Scanner::getSummary() : [];

		wp_send_json_success(
			[
				'tab'            => $tab,
				'html'           => self::renderTabContent( $tab ),
				'pending_review' => (int) ( $summary['pending_review'] ?? 0 ),
			]
		);
	}

	public function registerRestRoutes(): void {
		ModuleLoader::rest();
		ModuleLoader::contentScanner();
		( new RestController() )->register();
	}

	public function processQueue(): void {
		BulkGenerator::advanceQueueWhilePolling( 45 );
	}

	public function processContentScanBatch(): void {
		ModuleLoader::contentScanner();
		if ( class_exists( ContentScanner::class ) ) {
			ContentScanner::processBatch();
		}
	}

	public function handleExport(): void {
		if ( ! Capabilities::canView() ) {
			wp_die( esc_html__( 'Permission denied.', 'wp-powersuite' ) );
		}

		check_admin_referer( 'wpps_ai_alt_export' );

		ModuleLoader::importExport();
		if ( ! class_exists( Exporter::class, false ) ) {
			wp_die( esc_html__( 'CSV export is available in Pro.', 'wp-powersuite' ) );
		}

		$type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'missing';
		Exporter::download( $type );
	}

	public function handleSaveSettings(): void {
		if ( ! Capabilities::canManage() ) {
			wp_die( esc_html__( 'Permission denied.', 'wp-powersuite' ) );
		}

		check_admin_referer( 'wpps_ai_alt_save_settings' );

		$raw = isset( $_POST[ self::MODULE_ID ] ) && is_array( $_POST[ self::MODULE_ID ] )
			? wp_unslash( $_POST[ self::MODULE_ID ] )
			: [];

		Settings::persist( $raw );
		Capabilities::register();
		update_option( 'wpps_ai_alt_caps_version', self::MODULE_VERSION, false );

		do_action( 'wp_powersuite_module_settings_saved', self::MODULE_ID, Settings::get() );

		wp_safe_redirect(
			add_query_arg(
				[
					'page'           => self::DASHBOARD_SLUG,
					'tab'            => 'settings',
					'settings_saved' => '1',
				],
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	public function handleImport(): void {
		if ( ! Capabilities::canManage() ) {
			wp_die( esc_html__( 'Permission denied.', 'wp-powersuite' ) );
		}

		check_admin_referer( 'wpps_ai_alt_import' );

		ModuleLoader::importExport();
		if ( ! class_exists( Importer::class, false ) ) {
			wp_die( esc_html__( 'CSV import is available in Pro.', 'wp-powersuite' ) );
		}

		$file = isset( $_FILES['import_file'] ) && is_array( $_FILES['import_file'] ) ? $_FILES['import_file'] : [];
		$result = Importer::importFromUpload( $file );

		$redirect = add_query_arg(
			[
				'page'            => self::DASHBOARD_SLUG,
				'imported'        => (int) $result['imported'],
				'import_skipped'  => (int) $result['skipped'],
				'import_errors'   => count( $result['errors'] ),
			],
			admin_url( 'admin.php' )
		);

		if ( ! empty( $result['errors'] ) ) {
			set_transient( 'wpps_ai_alt_import_errors_' . get_current_user_id(), $result['errors'], MINUTE_IN_SECONDS );
		}

		wp_safe_redirect( $redirect );
		exit;
	}

	public function ajaxTestConnection(): void {
		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid security token.', 'wp-powersuite' ) ] );
		}

		if ( ! Capabilities::canManage() ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		$settings = Settings::get();
		$provider = Settings::sanitizeProvider( sanitize_key( wp_unslash( (string) ( $_POST['provider'] ?? $settings['provider'] ?? 'openai' ) ) ) );

		$api_key = Settings::getApiKey( $settings, $provider );
		if ( $api_key === '' ) {
			wp_send_json_error( [ 'message' => __( 'API key is missing for the selected provider.', 'wp-powersuite' ) ] );
		}

		$result = ProviderRegistry::get( $provider )->testConnection( [ 'api_key' => $api_key ] );
		if ( is_wp_error( $result ) ) {
			wp_send_json_error( [ 'message' => $result->get_error_message() ] );
		}

		wp_send_json_success( [ 'message' => __( 'API connection successful.', 'wp-powersuite' ) ] );
	}

	public function ajaxRegenerateDuplicates(): void {
		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid security token.', 'wp-powersuite' ) ] );
		}

		if ( ! Capabilities::canGenerate() ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		if ( ! self::checkRateLimit() ) {
			wp_send_json_error( [ 'message' => __( 'Rate limit reached. Please wait a moment.', 'wp-powersuite' ) ] );
		}

		wp_send_json_success( BulkActions::regenerateDuplicates() );
	}

	public function enqueueAdminAssets( string $hook ): void {
		global $pagenow;

		$is_dashboard = $hook === 'wp-powersuite_page_' . self::DASHBOARD_SLUG;
		$is_media     = $pagenow === 'upload.php'
			|| ( $pagenow === 'post.php' && isset( $_GET['post'] ) && in_array( get_post_type( (int) wp_unslash( $_GET['post'] ) ), [ 'attachment', 'product' ], true ) );

		if ( ! $is_dashboard && ! $is_media ) {
			return;
		}

		$base = WP_POWERSUITE_PLUGIN_URL . 'modules/' . self::MODULE_ID . '/assets/';
		wp_enqueue_style( 'wpps-ai-alt-text', $base . 'ai-alt-text.css', [], self::MODULE_VERSION );
		// Do not depend on media-grid — loading/patching alongside it breaks Media Library browse.
		$script_deps = [ 'jquery' ];
		if ( $is_media ) {
			$script_deps[] = 'media-views';
		}
		wp_enqueue_script( 'wpps-ai-alt-text', $base . 'ai-alt-text.js', $script_deps, self::MODULE_VERSION, true );

		if ( $is_dashboard && function_exists( 'wpps_enqueue_accordion_tooltip_script' ) ) {
			wpps_enqueue_accordion_tooltip_script();
		} elseif ( $is_dashboard && defined( 'WP_POWERSUITE_SRC_DIR' ) ) {
			$ui = WP_POWERSUITE_SRC_DIR . 'Admin' . DIRECTORY_SEPARATOR . 'ModuleSettingsUi.php';
			if ( is_readable( $ui ) ) {
				require_once $ui;
				if ( function_exists( 'wpps_enqueue_accordion_tooltip_script' ) ) {
					wpps_enqueue_accordion_tooltip_script();
				}
			}
		}

		$current_tab = isset( $_GET['tab'] ) ? self::resolveTab( sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) ) : 'dashboard';

		$localize = [
			'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
			'pageUrl'    => admin_url( 'admin.php?page=' . self::DASHBOARD_SLUG ),
			'initialTab' => $current_tab,
			'restUrl'    => rest_url( 'wp-powersuite/v1/ai-alt-text/' ),
			'restNonce'  => wp_create_nonce( 'wp_rest' ),
			'nonce'      => wp_create_nonce( self::NONCE_ACTION ),
			'moduleId'   => self::MODULE_ID,
			'isPro'      => FeatureGate::isPro(),
			'freeMonthlyLimit' => FeatureGate::effectiveMonthlyLimit( Settings::get() ),
			'licensePageUrl'   => admin_url( 'admin.php?page=powersuite-license' ),
			'strings'   => [
				'generating'   => __( 'Generating...', 'wp-powersuite' ),
				'saving'       => __( 'Saving...', 'wp-powersuite' ),
				'generated'    => __( 'Suggestion ready. Review and save when ready.', 'wp-powersuite' ),
				'generatedSaved' => __( 'Alt text generated and saved.', 'wp-powersuite' ),
				'saved'        => __( 'Saved.', 'wp-powersuite' ),
				'error'        => __( 'An error occurred.', 'wp-powersuite' ),
				'networkError' => __( 'Network error. Check your connection and try again.', 'wp-powersuite' ),
				'permissionError' => __( 'Permission denied. Refresh the page and try again.', 'wp-powersuite' ),
				'timeoutError' => __( 'The request timed out. Try a smaller batch size or increase the timeout in Settings.', 'wp-powersuite' ),
				'serverError'  => __( 'Server error. Check PHP limits and AI provider status.', 'wp-powersuite' ),
				'noApiKey'     => __( 'Configure API key in WP PowerSuite → AI Alt Text settings.', 'wp-powersuite' ),
				'confirmBulk'  => __( 'Generate AI alt text for selected images? This may use API credits.', 'wp-powersuite' ),
				'bulkStarting' => __( 'Preparing bulk job...', 'wp-powersuite' ),
				'bulkRunning'    => __( 'Generating alt text...', 'wp-powersuite' ),
				'bulkComplete'   => __( 'Bulk generation complete.', 'wp-powersuite' ),
				'bulkStopped'    => __( 'Bulk generation stopped.', 'wp-powersuite' ),
				'bulkRetryFailed' => __( 'Retry failed', 'wp-powersuite' ),
				'bulkRetrying'   => __( 'Retrying failed images...', 'wp-powersuite' ),
				'bulkRetryingStep' => __( 'Temporary server issue — retrying…', 'wp-powersuite' ),
				'bulkSkippingStuck' => __( 'Skipping stuck image and continuing…', 'wp-powersuite' ),
				'bulkResume'     => __( 'Resuming bulk generation...', 'wp-powersuite' ),
				'bulkNoImages'   => __( 'No images match the selected options.', 'wp-powersuite' ),
				'bulkStatusSuccess' => __( 'Success', 'wp-powersuite' ),
				'bulkStatusFailed'  => __( 'Failed', 'wp-powersuite' ),
				'bulkStatusSkipped' => __( 'Skipped', 'wp-powersuite' ),
				'bulkResultsEmpty'  => __( 'Results appear here as images are processed.', 'wp-powersuite' ),
				'bulkEditImage'     => __( 'Edit image', 'wp-powersuite' ),
				'generateWithAi'    => __( 'Generate with AI', 'wp-powersuite' ),
				'regenerateWithAi'  => __( 'Regenerate with AI', 'wp-powersuite' ),
				'generate'          => __( 'Generate', 'wp-powersuite' ),
				'unmarkDecorative'  => __( 'Unmark decorative', 'wp-powersuite' ),
				'decorativeNote'    => __( 'This image is decorative and skips AI alt text generation. Unmark it if the image conveys meaningful content.', 'wp-powersuite' ),
				'suggestionLabel'   => __( '(suggestion)', 'wp-powersuite' ),
				/* translators: %d: number value. */
				'bulkGenerateSingular' => __( 'Generate Alt Text for %d Image', 'wp-powersuite' ),
				/* translators: %d: number value. */
				'bulkGeneratePlural'   => __( 'Generate Alt Text for %d Images', 'wp-powersuite' ),
				'bulkGenerateNone'     => __( 'No images match selected filters', 'wp-powersuite' ),
				'bulkCountUpdating'    => __( 'Updating count...', 'wp-powersuite' ),
				/* translators: %d: number value. */
				'bulkMatchSingular'      => __( '%d image matches your filters', 'wp-powersuite' ),
				/* translators: %d: number value. */
				'bulkMatchPlural'        => __( '%d images match your filters', 'wp-powersuite' ),
				'bulkMatchNone'          => __( 'No images match your filters', 'wp-powersuite' ),
				'bulkOptionsNote'        => __( 'Recommended: process missing or weak alt first. Enable overwrite only when you want to replace alt text on images that already look good.', 'wp-powersuite' ),
				'bulkOverwriteNote'      => __( 'Overwrite enabled: all matching images will be included, including those that already have alt text.', 'wp-powersuite' ),
				'bulkQueued'   => __( 'Images added to background queue.', 'wp-powersuite' ),
				'rejected'     => __( 'Suggestion rejected.', 'wp-powersuite' ),
				'confirmDuplicates' => __( 'Regenerate alt text for all duplicate images? This uses API credits.', 'wp-powersuite' ),
				'tabLoading'        => __( 'Loading...', 'wp-powersuite' ),
			],
		];

		if ( $is_dashboard && Capabilities::canManage() ) {
			ModuleLoader::tab( 'settings' );
			require_once __DIR__ . '/includes/class-prompt-styles.php';
			$localize['modelHints'] = [
				'openai' => ProviderModels::getSettingsHint( 'openai', Settings::get() ),
				'gemini' => ProviderModels::getSettingsHint( 'gemini', Settings::get() ),
				'claude' => ProviderModels::getSettingsHint( 'claude', Settings::get() ),
			];
			$localize['promptStyleDescriptions'] = array_combine(
				PromptStyles::keys(),
				array_map(
					static fn( string $style ): string => PromptStyles::description( $style ),
					PromptStyles::keys()
				)
			);
		}

		if ( $is_media ) {
			$localize['canGenerate'] = Capabilities::canGenerate();
			$localize['hasApiKey']   = Settings::hasApiKey();
		}

		wp_localize_script( 'wpps-ai-alt-text', 'wppsAiAltText', $localize );
	}

	/**
	 * Expose alt-text status on media library attachment models (grid modal).
	 *
	 * @param array<string, mixed> $response   Attachment data for JS.
	 * @param \WP_Post             $attachment Attachment post.
	 * @param array<string, mixed> $meta       Attachment meta.
	 * @return array<string, mixed>
	 */
	public function prepareAttachmentForJs( array $response, \WP_Post $attachment, array $meta ): array {
		unset( $meta );

		// Skip enrichment during Media Library grid queries — keep the AJAX payload lean
		// and avoid any chance of corrupting query-attachments JSON.
		if ( wp_doing_ajax() ) {
			$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( (string) $_REQUEST['action'] ) ) : '';
			if ( $action === 'query-attachments' ) {
				return $response;
			}
		}

		if ( ! Capabilities::canGenerate() ) {
			return $response;
		}

		try {
			$attachment_id = (int) $attachment->ID;
			$status        = AttachmentStatus::resolve( $attachment_id );

			$response['wppsAiAltStatusSlug'] = $status['slug'];
			$response['wppsAiAltDecorative'] = AttachmentMeta::isDecorative( $attachment_id );
		} catch ( \Throwable $e ) {
			// Never break attachment JSON (object-cache / meta edge cases).
			unset( $e );
		}

		return $response;
	}

	public function addAltTextMetaBox(): void {
		add_meta_box(
			'wpps-ai-alt-text',
			__( 'AI Alt Text', 'wp-powersuite' ),
			[ $this, 'renderAltTextMetaBox' ],
			'attachment',
			'side'
		);
	}

	public function renderAltTextMetaBox( \WP_Post $post ): void {
		$current_alt   = (string) get_post_meta( $post->ID, '_wp_attachment_image_alt', true );
		$suggestion    = AttachmentMeta::getSuggestion( (int) $post->ID );
		$status        = AttachmentMeta::getStatus( (int) $post->ID );
		$is_decorative = AttachmentMeta::isDecorative( (int) $post->ID );
		$supported     = AltTextGenerator::isSupportedImage( (int) $post->ID );
		$has_generated = $current_alt !== ''
			|| $suggestion !== ''
			|| in_array( $status, [ 'generated', 'saved', 'needs_review' ], true );
		include __DIR__ . '/views/meta-box.php';
	}

	/** @param array<string, string> $actions */
	public function addBulkAction( array $actions ): array {
		if ( ! Capabilities::canGenerate() || ! Settings::hasApiKey() ) {
			return $actions;
		}

		$actions['wpps_ai_alt_text'] = __( 'Generate AI Alt Text', 'wp-powersuite' );
		return $actions;
	}

	public function handleBulkAction( string $redirect, string $action, array $post_ids ): string {
		if ( $action !== 'wpps_ai_alt_text' || empty( $post_ids ) ) {
			return $redirect;
		}

		if ( ! Capabilities::canGenerate() || ! Settings::hasApiKey() ) {
			return $redirect;
		}

		$post_ids = self::filterAuthorizedAttachmentIds( $post_ids );
		if ( $post_ids === [] ) {
			return $redirect;
		}

		$settings = Settings::get();
		if ( ! empty( $settings['queue_enabled'] ) ) {
			Queue::enqueue( $post_ids );
			return add_query_arg( 'wpps_ai_alt_queued', count( $post_ids ), admin_url( 'upload.php' ) );
		}

		return add_query_arg( [
			'wpps_ai_alt_bulk' => '1',
			'media'            => implode( ',', $post_ids ),
		], admin_url( 'upload.php' ) );
	}

	public function registerPostBulkActions(): void {
		foreach ( get_post_types( [ 'public' => true ], 'names' ) as $post_type ) {
			if ( in_array( $post_type, [ 'attachment' ], true ) ) {
				continue;
			}

			add_filter( "bulk_actions-edit-{$post_type}", [ $this, 'addPostBulkAction' ] );
			add_filter( "handle_bulk_actions-edit-{$post_type}", [ $this, 'handlePostBulkAction' ], 10, 3 );
		}

		if ( isset( $_GET['wpps_ai_alt_post_refresh'] ) ) {
			add_action( 'admin_notices', [ $this, 'postRefreshNotice' ] );
		}
	}

	public function addPostBulkAction( array $actions ): array {
		if ( ! Capabilities::canGenerate() || ! Settings::hasApiKey() ) {
			return $actions;
		}

		$actions['wpps_ai_alt_refresh_post'] = __( 'Refresh alt text in content', 'wp-powersuite' );

		return $actions;
	}

	public function handlePostBulkAction( string $redirect, string $action, array $post_ids ): string {
		if ( $action !== 'wpps_ai_alt_refresh_post' || $post_ids === [] ) {
			return $redirect;
		}

		if ( ! Capabilities::canGenerate() || ! Settings::hasApiKey() ) {
			return $redirect;
		}

		$result = PostRefresh::refreshPosts( $post_ids );

		return add_query_arg(
			[
				'wpps_ai_alt_post_refresh' => 1,
				'processed'                => (int) ( $result['processed'] ?? 0 ),
				'skipped'                  => (int) ( $result['skipped'] ?? 0 ),
				'failed'                   => (int) ( $result['failed'] ?? 0 ),
			],
			$redirect
		);
	}

	public function postRefreshNotice(): void {
		$processed = isset( $_GET['processed'] ) ? (int) $_GET['processed'] : 0;
		$skipped   = isset( $_GET['skipped'] ) ? (int) $_GET['skipped'] : 0;
		$failed    = isset( $_GET['failed'] ) ? (int) $_GET['failed'] : 0;
		?>
		<div class="notice notice-success is-dismissible">
			<p>
				<?php
				echo esc_html(
					sprintf(
						/* translators: 1: processed, 2: skipped, 3: failed */
						__( 'Post refresh complete. Refreshed %1$d images. Skipped %2$d. Failed %3$d.', 'wp-powersuite' ),
						$processed,
						$skipped,
						$failed
					)
				);
				?>
			</p>
		</div>
		<?php
	}

	public function maybeShowBulkNotice(): void {
		if ( isset( $_GET['wpps_ai_alt_queued'] ) ) {
			add_action( 'admin_notices', [ $this, 'queuedNotice' ] );
		}
		if ( isset( $_GET['wpps_ai_alt_bulk'], $_GET['media'] ) ) {
			add_action( 'admin_notices', [ $this, 'bulkNotice' ] );
		}
	}

	public function queuedNotice(): void {
		$count = isset( $_GET['wpps_ai_alt_queued'] ) ? (int) $_GET['wpps_ai_alt_queued'] : 0;
		if ( $count <= 0 ) {
			return;
		}
		?>
		<div class="notice notice-success is-dismissible">
			<p>
				<?php
				echo esc_html(
					(string) sprintf(
						/* translators: 1: number, 2: number. */
						_n( '%d image added to the background queue.', '%d images added to the background queue.', $count, 'wp-powersuite' ),
						$count
					)
				);
				?>
				<a href="<?php echo esc_url( admin_url( 'admin.php?page=' . self::DASHBOARD_SLUG ) ); ?>"><?php esc_html_e( 'View dashboard', 'wp-powersuite' ); ?></a>
			</p>
		</div>
		<?php
	}

	public function bulkNotice(): void {
		$media = isset( $_GET['media'] ) ? sanitize_text_field( wp_unslash( $_GET['media'] ) ) : '';
		$ids   = array_filter( array_map( 'intval', explode( ',', $media ) ) );
		if ( empty( $ids ) ) {
			return;
		}
		?>
		<div class="notice notice-info wpps-ai-alt-bulk-notice">
			<p>
				<?php
				printf(
					/* translators: 1: number, 2: number. */
					esc_html( _n( 'Generating AI alt text for %d image...', 'Generating AI alt text for %d images...', count( $ids ), 'wp-powersuite' ) ),
					count( $ids )
				);
				?>
			</p>
			<div class="wpps-ai-alt-bulk-progress" style="display:none">
				<div class="wpps-ai-alt-progress-bar"><div class="wpps-ai-alt-progress-fill"></div></div>
				<p class="wpps-ai-alt-progress-text">0 / <?php echo esc_html( (string) count( $ids ) ); ?></p>
			</div>
		</div>
		<?php
	}

	/**
	 * @param array<string, mixed> $metadata
	 * @return array<string, mixed>
	 */
	public function maybeScheduleAutoGenerateAfterMetadata( array $metadata, int $attachment_id ): array {
		if ( ! wp_attachment_is_image( $attachment_id ) ) {
			return $metadata;
		}

		$settings = Settings::get();
		if ( empty( $settings['auto_on_upload'] ) || ! Settings::hasApiKey() ) {
			return $metadata;
		}

		if ( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) !== '' ) {
			return $metadata;
		}

		if ( AttachmentMeta::isDecorative( $attachment_id ) ) {
			return $metadata;
		}

		if ( ImageFilter::blockingReason( $attachment_id, $settings ) !== null ) {
			return $metadata;
		}

		Queue::enqueue( [ $attachment_id ], false, 1 );

		return $metadata;
	}

	/**
	 * Process queued uploads on normal admin page loads (e.g. another browser tab).
	 */
	public function maybeProcessQueueOnAdminLoad(): void {
		if ( wp_doing_ajax() || wp_doing_cron() ) {
			return;
		}

		// async-upload.php handles media uploads without wp_doing_ajax().
		if ( ! empty( $_SERVER['SCRIPT_NAME'] ) && str_ends_with( (string) wp_unslash( $_SERVER['SCRIPT_NAME'] ), 'async-upload.php' ) ) {
			return;
		}

		$state = Queue::getState();
		if ( ( $state['status'] ?? '' ) !== 'running' || empty( $state['items'] ) ) {
			return;
		}

		BulkGenerator::advanceQueueWhilePolling( 15 );
	}

	private function verifyRequest(): void {
		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::prepare();
		}

		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid security token.', 'wp-powersuite' ) ] );
		}
		if ( ! Capabilities::canGenerate() ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
	}

	/**
	 * Soft per-user throttle for interactive single-image requests.
	 *
	 * Bulk sessions and the background queue intentionally skip this check;
	 * they already limit concurrency via batch_size and provider retries.
	 */
	public static function checkRateLimit(): bool {
		$user_id = get_current_user_id();
		if ( $user_id <= 0 ) {
			return true;
		}

		// DB option (not transient): object-cache hosts can leave stale transient counters
		// that permanently block interactive generation.
		$option = 'wpps_ai_alt_rate_' . $user_id;
		wp_cache_delete( $option, 'options' );
		wp_cache_delete( 'notoptions', 'options' );

		$now     = time();
		$payload = get_option( $option, false );
		$count   = 0;
		$expires = 0;

		if ( is_array( $payload ) ) {
			$expires = (int) ( $payload['expires'] ?? 0 );
			$count   = (int) ( $payload['count'] ?? 0 );
			if ( $expires > 0 && $expires < $now ) {
				$count   = 0;
				$expires = 0;
			}
		}

		if ( $count >= self::RATE_LIMIT_MAX ) {
			return false;
		}

		$window_end = ( $expires > $now ) ? $expires : ( $now + self::RATE_LIMIT_WINDOW );
		$next       = [
			'count'   => $count + 1,
			'expires' => $window_end,
		];

		if ( false === $payload || null === $payload ) {
			add_option( $option, $next, '', 'no' );
		} else {
			update_option( $option, $next, false );
		}

		wp_cache_delete( $option, 'options' );
		wp_cache_delete( 'notoptions', 'options' );

		return true;
	}

	/**
	 * @param list<int> $ids
	 * @return list<int>
	 */
	public static function filterAuthorizedAttachmentIds( array $ids ): array {
		return array_values(
			array_filter(
				array_map( 'intval', $ids ),
				static function ( int $id ): bool {
					return $id > 0 && current_user_can( 'edit_post', $id );
				}
			)
		);
	}

	/**
	 * @param list<int> $ids
	 * @return list<int>
	 */
	public static function limitBulkIds( array $ids ): array {
		return array_slice( self::filterAuthorizedAttachmentIds( $ids ), 0, self::MAX_BULK_IDS );
	}

	public function ajaxGenerate(): void {
		$this->verifyRequest();

		$attachment_id = isset( $_POST['attachment_id'] ) ? (int) $_POST['attachment_id'] : 0;
		if ( ! $attachment_id || ! current_user_can( 'edit_post', $attachment_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}

		if ( ! Settings::hasApiKey() ) {
			wp_send_json_error( [ 'message' => __( 'Configure API key in WP PowerSuite → AI Alt Text settings.', 'wp-powersuite' ) ] );
		}

		if ( ! self::checkRateLimit() ) {
			wp_send_json_error( [ 'message' => __( 'Rate limit reached. Please wait a moment before generating more alt text.', 'wp-powersuite' ) ] );
		}

		$result = GenerationService::generateForAttachment( $attachment_id, ! empty( $_POST['force'] ) );
		if ( $result['status'] !== 'success' ) {
			wp_send_json_error(
				[
					'message' => $result['message'] ?? __( 'Generation failed.', 'wp-powersuite' ),
					'code'    => (string) ( $result['code'] ?? GenerationError::CODE_INTERNAL ),
				]
			);
		}

		$file     = get_attached_file( $attachment_id, true );
		$filename = is_string( $file ) && $file !== '' ? basename( $file ) : '';
		$status   = AttachmentStatus::resolve( $attachment_id );
		$alt_text = (string) ( $result['alt_text'] ?? '' );
		$settings = Settings::get();
		$alt_saved = ! empty( $settings['auto_approve_suggestions'] );

		wp_send_json_success( [
			'alt_text'      => $alt_text,
			'old_alt'       => $result['old_alt'] ?? '',
			'log_status'    => 'success',
			'attachment_id' => $attachment_id,
			'filename'      => $filename,
			'created_at'    => current_time( 'mysql' ),
			'status'        => AttachmentMeta::getStatus( $attachment_id ),
			'status_slug'   => $status['slug'],
			'status_label'  => $status['label'],
			'badge_class'   => AttachmentStatus::badgeClass( $status['slug'] ),
			'alt_saved'     => $alt_saved,
			'is_suggestion' => ! $alt_saved,
			'alt_display'   => self::truncateAltDisplay( $alt_text ),
		] );
	}

	public function ajaxSave(): void {
		$this->verifyRequest();

		$attachment_id = isset( $_POST['attachment_id'] ) ? (int) $_POST['attachment_id'] : 0;
		$alt_text      = isset( $_POST['alt_text'] ) ? sanitize_text_field( wp_unslash( $_POST['alt_text'] ) ) : '';

		if ( ! $attachment_id || ! current_user_can( 'edit_post', $attachment_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}

		AttachmentMeta::saveAltText( $attachment_id, $alt_text );
		delete_post_meta( $attachment_id, AttachmentMeta::META_SUGGESTION );
		Scanner::invalidateCache();
		$status = AttachmentStatus::resolve( $attachment_id );

		wp_send_json_success( [
			'message'       => __( 'Saved.', 'wp-powersuite' ),
			'alt_text'      => $alt_text,
			'attachment_id' => $attachment_id,
			'status_slug'   => $status['slug'],
			'status_label'  => $status['label'],
			'badge_class'   => AttachmentStatus::badgeClass( $status['slug'] ),
			'alt_saved'     => true,
			'is_suggestion' => false,
			'alt_display'   => self::truncateAltDisplay( $alt_text ),
		] );
	}

	public function ajaxBulk(): void {
		$this->verifyRequest();

		$ids = self::limitBulkIds( isset( $_POST['attachment_ids'] ) ? (array) $_POST['attachment_ids'] : [] );

		if ( $ids === [] ) {
			wp_send_json_error( [ 'message' => __( 'No images selected.', 'wp-powersuite' ) ] );
		}

		if ( ! Settings::hasApiKey() ) {
			wp_send_json_error( [ 'message' => __( 'Configure API key in WP PowerSuite → AI Alt Text settings.', 'wp-powersuite' ) ] );
		}

		$settings   = Settings::get();
		$batch_size = Settings::clampBatchSize( (int) ( $settings['batch_size'] ?? 5 ) );
		$batch      = array_slice( $ids, 0, $batch_size );
		$processed  = [];
		$failed     = [];

		foreach ( $batch as $attachment_id ) {
			if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
				$failed[] = [
					'id'      => $attachment_id,
					'message' => __( 'You do not have permission to edit this attachment.', 'wp-powersuite' ),
					'code'    => GenerationError::CODE_PERMISSION,
				];
				continue;
			}

			$result = GenerationService::generateForAttachment( $attachment_id );
			if ( $result['status'] === 'success' ) {
				$processed[] = [
					'id'       => $attachment_id,
					'alt_text' => $result['alt_text'] ?? '',
					'status'   => AttachmentMeta::getStatus( $attachment_id ),
				];
			} else {
				$failed[] = [
					'id'      => $attachment_id,
					'message' => $result['message'] ?? __( 'Failed.', 'wp-powersuite' ),
					'code'    => (string) ( $result['code'] ?? GenerationError::CODE_INTERNAL ),
				];
			}
		}

		wp_send_json_success( [
			'processed' => $processed,
			'failed'    => $failed,
			'remaining' => array_slice( $ids, $batch_size ),
		] );
	}

	public function ajaxBulkStart(): void {
		$this->verifyRequest();

		if ( ! Settings::hasApiKey() ) {
			wp_send_json_error(
				[
					'message' => __( 'Configure your API key in Settings before bulk generating.', 'wp-powersuite' ),
					'code'    => GenerationError::CODE_API_KEY,
				]
			);
		}

		$active = BulkGenerator::getActiveBulk();
		if ( $active !== null && $active['mode'] === 'session' && $active['id'] !== '' ) {
			$existing = BulkGenerator::getSessionStatus( $active['id'] );
			if ( is_array( $existing ) && empty( $existing['done'] ) ) {
				wp_send_json_error(
					[
						'message' => __( 'A bulk generation job is already running. Stop it before starting another.', 'wp-powersuite' ),
						'code'    => 'duplicate_job',
						'session' => $active['id'],
					]
				);
			}
		}

		$options = $this->bulkOptionsFromRequest();

		// Fall back to saved defaults when the bulk fields were left empty.
		if ( ( $options['keywords']['seo'] ?? [] ) === [] ) {
			$defaults = BulkGenerator::sanitizeKeywordList( (string) ( Settings::get()['default_seo_keywords'] ?? '' ) );
			if ( $defaults !== [] ) {
				$options['keywords']['seo'] = $defaults;
			}
		}
		if ( ( $options['keywords']['negative'] ?? [] ) === [] ) {
			$defaults = BulkGenerator::sanitizeKeywordList( (string) ( Settings::get()['default_negative_keywords'] ?? '' ) );
			if ( $defaults !== [] ) {
				$options['keywords']['negative'] = $defaults;
			}
		}

		// Remember effective keywords so the Bulk tab stays prefilled next time.
		Settings::persistBulkKeywords(
			implode( ', ', $options['keywords']['seo'] ?? [] ),
			implode( ', ', $options['keywords']['negative'] ?? [] )
		);

		$count = BulkGenerator::countTargets( $options );

		if ( $count <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'No images match the selected bulk options.', 'wp-powersuite' ) ] );
		}

		$started = BulkGenerator::startSession( $options );
		if ( $started['total'] <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'No images match the selected bulk options.', 'wp-powersuite' ) ] );
		}

		BulkGenerator::setActiveBulk( 'session', (string) $started['session'] );
		wp_send_json_success(
			array_merge(
				$started,
				[
					'mode'              => 'session',
					'keywords_seo'      => $options['keywords']['seo'] ?? [],
					'keywords_negative' => $options['keywords']['negative'] ?? [],
				]
			)
		);
	}

	public function ajaxBulkStatus(): void {
		$this->verifyRequest();

		$status = BulkGenerator::getProgressStatus();
		if ( $status === null ) {
			wp_send_json_success( [ 'mode' => 'idle', 'status' => 'idle' ] );
		}

		wp_send_json_success( $status );
	}

	public function ajaxBulkRetryFailed(): void {
		$this->verifyRequest();

		if ( ! Settings::hasApiKey() ) {
			wp_send_json_error( [ 'message' => __( 'Configure your API key in Settings before bulk generating.', 'wp-powersuite' ) ] );
		}

		$session = isset( $_POST['session'] ) ? sanitize_key( wp_unslash( (string) $_POST['session'] ) ) : '';

		if ( $session === '' ) {
			wp_send_json_error( [ 'message' => __( 'No failed images to retry.', 'wp-powersuite' ) ] );
		}

		$started = BulkGenerator::retryFailedSession( $session );
		if ( $started === null || ( $started['total'] ?? 0 ) <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'No failed images to retry.', 'wp-powersuite' ) ] );
		}

		BulkGenerator::setActiveBulk( 'session', (string) $started['session'] );
		wp_send_json_success( array_merge( $started, [ 'mode' => 'session' ] ) );
	}

	public function ajaxBulkPreview(): void {
		if ( class_exists( \WP_PowerSuite\Core\LongAjaxResponse::class ) ) {
			\WP_PowerSuite\Core\LongAjaxResponse::prepare();
		}

		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid security token.', 'wp-powersuite' ) ] );
		}

		if ( ! Capabilities::canGenerate() ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		$options = $this->bulkOptionsFromRequest();
		$stats   = BulkGenerator::getPreviewStats( $options );

		wp_send_json_success(
			[
				'count' => $stats['to_process'],
				'stats' => $stats,
			]
		);
	}

	public function ajaxDashboardStats(): void {
		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid security token.', 'wp-powersuite' ) ] );
		}

		if ( ! Capabilities::canView() ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		$refresh = ! empty( $_POST['refresh'] );
		if ( $refresh ) {
			Scanner::invalidateCache();
		}

		wp_send_json_success(
			[
				'library' => Scanner::getSummary( $refresh ),
				'usage'     => Usage::getSummary(),
			]
		);
	}

	/**
	 * @return array{overwrite: bool, only_attached: bool, skip_processed: bool, woo_only: bool, woo_main_only: bool, target: string}
	 */
	private function bulkOptionsFromRequest(): array {
		return BulkGenerator::normalizeOptions(
			[
				'overwrite'      => ! empty( $_POST['overwrite'] ),
				'only_attached'  => ! empty( $_POST['only_attached'] ),
				'skip_processed' => ! empty( $_POST['skip_processed'] ),
				'woo_only'       => ! empty( $_POST['woo_only'] ),
				'woo_main_only'  => ! empty( $_POST['woo_main_only'] ),
				'seo_keywords'      => isset( $_POST['seo_keywords'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['seo_keywords'] ) ) : '',
				'negative_keywords' => isset( $_POST['negative_keywords'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['negative_keywords'] ) ) : '',
				'target'         => isset( $_POST['target'] ) ? sanitize_key( wp_unslash( (string) $_POST['target'] ) ) : 'missing',
			]
		);
	}

	public function ajaxBulkStep(): void {
		$this->verifyRequest();

		$session = isset( $_POST['session'] ) ? sanitize_key( wp_unslash( (string) $_POST['session'] ) ) : '';
		if ( $session === '' ) {
			wp_send_json_error( [ 'message' => __( 'Invalid bulk session.', 'wp-powersuite' ) ] );
		}

		if ( ! Settings::hasApiKey() ) {
			wp_send_json_error(
				[
					'message' => __( 'API key is not configured.', 'wp-powersuite' ),
					'code'    => GenerationError::CODE_API_KEY,
				]
			);
		}

		$skip_current = ! empty( $_POST['skip_current'] );

		// Shared hosts (e.g. Hostinger) often kill long AJAX requests — raise the limit when allowed.
		Text::bumpTimeLimit( 90 );

		try {
			wp_send_json_success( BulkGenerator::processNext( $session, $skip_current ) );
		} catch ( \Throwable $e ) {
			Diagnostics::recordBulkError(
				[
					'code'          => GenerationError::CODE_INTERNAL,
					'message'       => $e->getMessage(),
					'attachment_id' => 0,
				]
			);
			Diagnostics::debug(
				'bulk_step_exception',
				[
					'message' => $e->getMessage(),
					'file'    => basename( $e->getFile() ),
					'line'    => $e->getLine(),
				]
			);

			wp_send_json_error(
				[
					'message'   => __( 'Bulk processing hit a server limit or internal error. The job can be resumed — try again, or lower batch size / request timeout in Settings.', 'wp-powersuite' ),
					'code'      => GenerationError::CODE_SERVER,
					'retryable' => true,
					'detail'    => wp_get_environment_type() === 'production' ? '' : $e->getMessage(),
				]
			);
		}
	}

	public function ajaxBulkCancel(): void {
		$this->verifyRequest();

		$session = isset( $_POST['session'] ) ? sanitize_key( wp_unslash( (string) $_POST['session'] ) ) : '';

		if ( $session !== '' ) {
			BulkGenerator::cancelSession( $session );
		}

		BulkGenerator::clearActiveBulk();
		wp_send_json_success( [ 'message' => __( 'Bulk generation stopped.', 'wp-powersuite' ) ] );
	}

	public function ajaxLocalSuggest(): void {
		$this->verifyRequest();

		$attachment_id = isset( $_POST['attachment_id'] ) ? (int) $_POST['attachment_id'] : 0;
		if ( ! $attachment_id || ! current_user_can( 'edit_post', $attachment_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}

		$suggestion = LocalSuggester::suggest( $attachment_id );
		if ( $suggestion === '' ) {
			wp_send_json_error( [ 'message' => __( 'No metadata available for a local suggestion.', 'wp-powersuite' ) ] );
		}

		AttachmentMeta::storeSuggestion( $attachment_id, $suggestion, 'needs_review' );
		wp_send_json_success( [ 'alt_text' => $suggestion ] );
	}

	public function ajaxMarkDecorative(): void {
		$this->verifyRequest();

		$attachment_id = isset( $_POST['attachment_id'] ) ? (int) $_POST['attachment_id'] : 0;
		if ( ! $attachment_id || ! current_user_can( 'edit_post', $attachment_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}

		AttachmentMeta::markDecorative( $attachment_id );
		Scanner::invalidateCache();
		$status = AttachmentStatus::resolve( $attachment_id );

		wp_send_json_success( [
			'message'       => __( 'Marked as decorative.', 'wp-powersuite' ),
			'attachment_id' => $attachment_id,
			'alt_text'      => '',
			'alt_display'   => '',
			'alt_saved'     => true,
			'is_suggestion' => false,
			'is_decorative' => true,
			'status_slug'   => $status['slug'],
			'status_label'  => $status['label'],
			'badge_class'   => AttachmentStatus::badgeClass( $status['slug'] ),
		] );
	}

	public function ajaxUnmarkDecorative(): void {
		$this->verifyRequest();

		$attachment_id = isset( $_POST['attachment_id'] ) ? (int) $_POST['attachment_id'] : 0;
		if ( ! $attachment_id || ! current_user_can( 'edit_post', $attachment_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}

		if ( ! AttachmentMeta::isDecorative( $attachment_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Image is not marked decorative.', 'wp-powersuite' ) ] );
		}

		AttachmentMeta::unmarkDecorative( $attachment_id );
		Scanner::invalidateCache();
		$status = AttachmentStatus::resolve( $attachment_id );

		wp_send_json_success( [
			'message'       => __( 'Decorative mark removed. You can add alt text or generate with AI.', 'wp-powersuite' ),
			'attachment_id' => $attachment_id,
			'alt_text'      => '',
			'alt_display'   => '',
			'alt_saved'     => false,
			'is_suggestion' => false,
			'is_decorative' => false,
			'status_slug'   => $status['slug'],
			'status_label'  => $status['label'],
			'badge_class'   => AttachmentStatus::badgeClass( $status['slug'] ),
		] );
	}

	public function ajaxReject(): void {
		$this->verifyRequest();

		$attachment_id = isset( $_POST['attachment_id'] ) ? (int) $_POST['attachment_id'] : 0;
		if ( ! $attachment_id || ! current_user_can( 'edit_post', $attachment_id ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}

		update_post_meta( $attachment_id, AttachmentMeta::META_STATUS, 'rejected' );
		delete_post_meta( $attachment_id, AttachmentMeta::META_SUGGESTION );
		Scanner::invalidateCache();
		wp_send_json_success( [ 'message' => __( 'Suggestion rejected.', 'wp-powersuite' ) ] );
	}

	private static function truncateAltDisplay( string $text, int $max = 80 ): string {
		$text = trim( $text );
		if ( $text === '' ) {
			return '';
		}

		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
			return mb_strlen( $text ) > $max ? mb_substr( $text, 0, $max - 1 ) . '…' : $text;
		}

		return strlen( $text ) > $max ? substr( $text, 0, $max - 1 ) . '…' : $text;
	}
}

