/**
 * External dependencies
 */
import { v4 as uuidv4 } from 'uuid';

/**
 * WordPress dependencies
 */
import type { createRegistry } from '@wordpress/data';
import { __ } from '@wordpress/i18n';

type WPDataRegistry = ReturnType< typeof createRegistry >;

/**
 * Internal dependencies
 */
import type {
	AdditionalData,
	CancelAction,
	OnBatchSuccessHandler,
	OnChangeHandler,
	OnErrorHandler,
	OnSuccessHandler,
	QueueItemId,
	RetryItemAction,
	ScheduleRetryAction,
	State,
} from './types';
import { ItemStatus, OperationType, Type } from './types';
import {
	calculateRetryDelay,
	clearRetryTimer,
	retryTimers,
	shouldRetryError,
} from './utils/retry';
import type {
	addItem,
	processItem,
	removeItem,
	revokeBlobUrls,
} from './private-actions';
import { maybeRecycleVipsWorker, vipsCancelOperations } from './utils';
import { cancelGifToVideoOperations } from './utils/video-conversion';
import { debug } from './utils/debug-logger';
import { ErrorCode, UploadError } from '../upload-error';
import { validateMimeType } from '../validate-mime-type';
import { validateMimeTypeForUser } from '../validate-mime-type-for-user';
import { validateFileSize } from '../validate-file-size';

type ActionCreators = {
	addItem: typeof addItem;
	addItems: typeof addItems;
	removeItem: typeof removeItem;
	processItem: typeof processItem;
	cancelItem: typeof cancelItem;
	retryItem: typeof retryItem;
	scheduleRetry: typeof scheduleRetry;
	executeRetry: typeof executeRetry;
	revokeBlobUrls: typeof revokeBlobUrls;
	< T = Record< string, unknown > >( args: T ): void;
};

type AllSelectors = typeof import('./selectors') &
	typeof import('./private-selectors');
type CurriedState< F > = F extends ( state: State, ...args: infer P ) => infer R
	? ( ...args: P ) => R
	: F;
type Selectors = {
	[ key in keyof AllSelectors ]: CurriedState< AllSelectors[ key ] >;
};

type ThunkArgs = {
	select: Selectors;
	dispatch: ActionCreators;
	registry: WPDataRegistry;
};

interface AddItemsArgs {
	files: File[];
	onChange?: OnChangeHandler;
	onSuccess?: OnSuccessHandler;
	onBatchSuccess?: OnBatchSuccessHandler;
	onError?: OnErrorHandler;
	additionalData?: AdditionalData;
	allowedTypes?: string[];
}

/**
 * Adds a new item to the upload queue.
 *
 * @param $0
 * @param $0.files            Files
 * @param [$0.onChange]       Function called each time a file or a temporary representation of the file is available.
 * @param [$0.onSuccess]      Function called after the file is uploaded.
 * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded.
 * @param [$0.onError]        Function called when an error happens.
 * @param [$0.additionalData] Additional data to include in the request.
 * @param [$0.allowedTypes]   Array with the types of media that can be uploaded, if unset all types are allowed.
 */
export function addItems( {
	files,
	onChange,
	onSuccess,
	onError,
	onBatchSuccess,
	additionalData,
	allowedTypes,
}: AddItemsArgs ) {
	return async ( { select, dispatch }: ThunkArgs ) => {
		const batchId = uuidv4();
		for ( const file of files ) {
			/*
			 Check if the caller (e.g. a block) supports this mime type.
			 Special case for file types such as HEIC which will be converted before upload anyway.
			 Another check will be done before upload.
			*/
			try {
				validateMimeType( file, allowedTypes );
				validateMimeTypeForUser(
					file,
					select.getSettings().allowedMimeTypes
				);
			} catch ( error: unknown ) {
				onError?.( error as Error );
				continue;
			}

			try {
				validateFileSize(
					file,
					select.getSettings().maxUploadFileSize
				);
			} catch ( error: unknown ) {
				onError?.( error as Error );
				continue;
			}

			dispatch.addItem( {
				file,
				batchId,
				onChange,
				onSuccess,
				onBatchSuccess,
				onError,
				additionalData,
			} );
		}
	};
}

/**
 * Cancels an item in the queue based on an error.
 *
 * If the error is retryable and the item hasn't exceeded the maximum
 * retry attempts, it will be scheduled for automatic retry instead
 * of being cancelled.
 *
 * @param id     Item ID.
 * @param error  Error instance.
 * @param silent Whether to cancel the item silently,
 *               without invoking its `onError` callback.
 */
export function cancelItem( id: QueueItemId, error: Error, silent = false ) {
	return async ( { select, dispatch }: ThunkArgs ) => {
		const item = select.getItem( id );

		if ( ! item ) {
			/*
			 * Do nothing if item has already been removed.
			 * This can happen if an upload is cancelled manually
			 * while transcoding with vips is still in progress.
			 * Then, cancelItem() is once invoked manually and once
			 * by the error handler in optimizeImageItem().
			 */
			return;
		}

		// Clear any pending retry timer for this item.
		clearRetryTimer( id );

		// Check if we should automatically retry instead of cancelling.
		// Child sideload items are excluded: the parent owns the upload
		// lifecycle and decides whether a sub-size failure should cancel
		// the whole attachment or keep the partially-uploaded sub-sizes.
		// Items whose primary upload already finished (attachment exists)
		// are also excluded — the cancellation is cleanup, not a retry.
		if ( ! silent && error && ! item.parentId && ! item.attachment?.id ) {
			const settings = select.getSettings();
			const retrySettings = settings.retry;

			if ( retrySettings ) {
				const retryCount = item.retryCount ?? 0;
				const maxRetries = retrySettings.maxRetryAttempts;

				if ( shouldRetryError( error, retryCount, maxRetries ) ) {
					dispatch.scheduleRetry( id, error );
					return;
				}
			}
		}

		item.abortController?.abort();

		// Cancel any ongoing vips operations for this item.
		await vipsCancelOperations( id );

		/*
		 * Cancel any ongoing GIF-to-video conversion for this item so a
		 * cancelled upload does not leave the encoder running off-thread.
		 */
		await cancelGifToVideoOperations( id );

		if ( ! silent ) {
			const { onError } = item;
			onError?.( error ?? new Error( 'Upload cancelled' ) );
			if ( ! onError && error && ! item.parentId ) {
				// Log errors for top-level items without an onError handler.
				// Child sideload errors are suppressed here because the
				// parent will be notified and surface the error to the user.
				// eslint-disable-next-line no-console -- Deliberately log errors here.
				console.error( 'Upload cancelled', error );
			}
		} else {
			debug(
				`Item cancelled: ${ item.file.name } (item ${ id }): ${
					error instanceof Error ? error.message : error
				}`
			);
		}

		const { currentOperation, parentId, batchId } = item;

		dispatch< CancelAction >( {
			type: Type.Cancel,
			id,
			error,
		} );
		dispatch.removeItem( id );
		dispatch.revokeBlobUrls( id );

		// A concurrency slot just freed up. Kick any items that were
		// waiting in the queue, mirroring finishOperation's behavior.
		if (
			currentOperation === OperationType.ResizeCrop ||
			currentOperation === OperationType.Rotate
		) {
			for ( const pending of select.getPendingImageProcessing() ) {
				dispatch.processItem( pending.id );
			}
		}
		if ( currentOperation === OperationType.Upload ) {
			for ( const pending of select.getPendingUploads() ) {
				dispatch.processItem( pending.id );
			}
		}
		if ( currentOperation === OperationType.TranscodeGif ) {
			for ( const pending of select.getPendingVideoProcessing() ) {
				dispatch.processItem( pending.id );
			}
		}

		// Failed vips ops also leak WASM memory, so count them toward the
		// recycle budget. Without this, a long burst of failures (e.g. a
		// gallery of unsupported AVIFs) could grow memory unbounded.
		if (
			currentOperation === OperationType.ResizeCrop ||
			currentOperation === OperationType.Rotate ||
			currentOperation === OperationType.TranscodeImage
		) {
			maybeRecycleVipsWorker( select.getActiveImageProcessingCount() );
		}

		// If this was a child sideload item, handle the parent.
		if ( parentId ) {
			const parentItem = select.getItem( parentId );
			if ( parentItem ) {
				/*
				 * The converted video and its poster are optional companions
				 * of an animated GIF: the parent GIF attachment is fine
				 * without them. Their failure must never be treated as a total
				 * parent failure (which would delete the already-uploaded GIF),
				 * even when the companion is the only child sideload.
				 */
				const isOptionalCompanion =
					item.additionalData?.image_size === 'animated_video' ||
					item.additionalData?.image_size === 'animated_video_poster';

				if ( select.hasPendingItemsByParentId( parentId ) ) {
					// Other children remain — just notify the parent so
					// it can re-check the Finalize gate.
					if (
						parentItem.operations &&
						parentItem.operations.length > 0
					) {
						dispatch.processItem( parentId );
					}
				} else if (
					( parentItem.subSizes && parentItem.subSizes.length > 0 ) ||
					isOptionalCompanion
				) {
					/*
					 * Partial success: at least one child sideload succeeded
					 * (its sub-size is already accumulated on the parent), or
					 * the failed child was an optional companion. Keep the
					 * parent attachment and finalize with whichever sub-sizes
					 * did succeed — matching WordPress core's best-effort
					 * behavior when individual sub-size generations fail.
					 */
					if (
						parentItem.operations &&
						parentItem.operations.length > 0
					) {
						dispatch.processItem( parentId );
					}
				} else {
					// Total failure: no child succeeded. The parent file
					// already uploaded — delete the orphaned attachment
					// from the server so it doesn't appear in the media
					// library.
					const parentAttachmentId = parentItem.attachment?.id;
					const { mediaDelete } = select.getSettings();
					if ( parentAttachmentId && mediaDelete ) {
						mediaDelete( parentAttachmentId ).catch( () => {
							// Best-effort cleanup; surface nothing to the
							// user if the delete itself fails.
						} );
					}

					// Cancel the parent too so the block resets rather
					// than showing a partial upload. Propagate the
					// underlying error's code and message — vips
					// processing failures already carry an actionable
					// hint at their source; network/server failures
					// surface their real cause. Awaited so the cascade
					// fully settles (parent removed, onError fired) before
					// this thunk resolves and the batch-completion check
					// below runs.
					await dispatch.cancelItem(
						parentId,
						new UploadError( {
							code:
								( error instanceof UploadError &&
									error.code ) ||
								ErrorCode.GENERAL,
							message:
								error?.message ||
								__( 'The image could not be uploaded.' ),
							file: parentItem.file,
							cause: error instanceof Error ? error : undefined,
						} )
					);
				}
			}
		}

		// All items of this batch were cancelled or finished.
		if ( batchId && select.isBatchUploaded( batchId ) ) {
			debug( `Batch completed: ${ batchId }` );
			item.onBatchSuccess?.();
		}
	};
}

/**
 * Retries a failed item in the queue.
 *
 * @param id Item ID.
 */
export function retryItem( id: QueueItemId ) {
	return async ( { select, dispatch }: ThunkArgs ) => {
		const item = select.getItem( id );

		if ( ! item ) {
			return;
		}

		// Only retry items that have an error.
		if ( ! item.error ) {
			return;
		}

		dispatch< RetryItemAction >( {
			type: Type.RetryItem,
			id,
		} );

		dispatch.processItem( id );
	};
}

/**
 * Schedules an automatic retry for a failed item.
 *
 * Uses exponential backoff with jitter to determine the retry delay.
 * The item will be placed in PendingRetry status and automatically
 * retried after the calculated delay.
 *
 * @param id    Item ID.
 * @param error The error that caused the failure.
 */
export function scheduleRetry( id: QueueItemId, error: Error ) {
	return async ( { select, dispatch }: ThunkArgs ) => {
		const item = select.getItem( id );
		if ( ! item ) {
			return;
		}

		const settings = select.getSettings();
		const retrySettings = settings.retry;

		if ( ! retrySettings ) {
			return;
		}

		const currentRetryCount = item.retryCount ?? 0;

		const delay = calculateRetryDelay( {
			attempt: currentRetryCount + 1,
			initialDelay: retrySettings.initialRetryDelayMs,
			maxDelay: retrySettings.maxRetryDelayMs,
			multiplier: retrySettings.backoffMultiplier,
			jitter: retrySettings.retryJitter,
		} );

		// Schedule the retry execution and store timer ID for cleanup.
		const timerId = setTimeout( () => {
			retryTimers.delete( id );
			dispatch.executeRetry( id );
		}, delay );
		retryTimers.set( id, timerId );

		dispatch< ScheduleRetryAction >( {
			type: Type.ScheduleRetry,
			id,
			error,
			retryCount: currentRetryCount,
			nextRetryTimestamp: Date.now() + delay,
		} );
	};
}

/**
 * Executes a scheduled retry for an item.
 *
 * This is called by the timer set in scheduleRetry.
 * It verifies the item is still in PendingRetry status before
 * proceeding with the retry.
 *
 * @param id Item ID.
 */
export function executeRetry( id: QueueItemId ) {
	return async ( { select, dispatch }: ThunkArgs ) => {
		const item = select.getItem( id );

		// Verify item exists and is still pending retry
		// (user may have manually cancelled or retried).
		if ( ! item || item.status !== ItemStatus.PendingRetry ) {
			return;
		}

		// If the queue is paused, leave the item in PendingRetry without
		// mutating state. resumeQueue will re-trigger executeRetry for
		// items in this status when the queue resumes.
		if ( select.isPaused() ) {
			return;
		}

		// Reset the item to Processing status and clear the error.
		dispatch< RetryItemAction >( {
			type: Type.RetryItem,
			id,
		} );

		// Re-process the item.
		dispatch.processItem( id );
	};
}
