//	Draw4DThumbnailProvider.swift
//
//	© 2025 by Jeff Weeks
//	See TermsOfUse.txt

import QuickLookThumbnailing

enum Draw4DThumbnailError: Error {
	case invalidFileURL
	case invalidUTF8Data
	case invalidDrawingData
	case failedToCreateMetalDevice
	case failedToCreateMetalRenderer
	case failedToRenderImage
}


//	QLThumbnailProvider is nonisolated,
//	so let's make our subclass nonisolated too.
nonisolated class Draw4DThumbnailProvider: QLThumbnailProvider {

	//	Crucial detail:  Our documents' "Uniform Type Identifiers" (UTI)
	//	should be {public.data, public.content}, not {public.plain-text}.
	//	Even though the documents really are plain-text files,
	//	if we declare them as {public.plain-text}, iOS will ignore
	//	our Draw4DThumbnailProvider and instead create thumbnails
	//	showing each drawing's plain-text source code.
	//
	//	Non-obvious pitfall:  iOS apparently caches our documents' UTI
	//	so deeply that changes have no effect.  In particular, if the UTI is set
	//	to {public.plain-text} and iOS is ignoring our Draw4DThumbnailProvider,
	//	then changing the UTI to {public.data, public.content} will have no effect --
	//	the cached {public.plain-text} value will continue to be used,
	//	and iOS will continue to ignore our Draw4DThumbnailProvider.
	//	Even deleting the app and rebooting the device didn't help.
	//	To solve this problem, I ended up changing the bundle identifier
	//	(see instructions below), although in retrospect changing
	//	the build number might have been a simpler solution.
	//
	//	Corollary:  Don't switch to the app's permanent bundle ID
	//	until you're 100% sure that your QLThumbnailProvider subclass
	//	is working the way you want it to.
	//
	//	More details:
	//
	//	When migrating from the temporary bundle identifier
	//
	//		org.geometrygames.Draw4D-temp
	//
	//	to the permanent
	//
	//		org.geometrygames.Draw4D-mobile
	//
	//	note that the string 'Draw4D-temp' needs to get changed in 5 places!
	//
	//		Build settings "4D Draw" target
	//			PRODUCT_BUNDLE_IDENTIFIER = org.geometrygames.Draw4D-temp
	//
	//		Build settings "4D Draw Thumbnails" target
	//			PRODUCT_BUNDLE_IDENTIFIER = org.geometrygames.Draw4D-temp.Thumbnails
	//
	//		Draw4DDocument.swift
	//			UTType(exportedAs: "org.geometrygames.Draw4D-temp.drawing")
	//
	//		4D Draw Info.plist
	//			Exported Types Identifiers > Item 0 > Identifiers
	//				org.geometrygames.Draw4D-temp.drawing
	//
	//		4D Draw Thumbnails Info.plist
	//			NSExtension > NSExtensionAttributes > QLSupportedContentTypes > Item 0
	//			   org.geometrygames.Draw4D-temp.drawing
	//
	//	Search for it and replace them all, or otherwise the thumbnails
	//	won't get properly created.
	//
	//	Reference:  Apple's page
	//
	//		Building a Document Browser App for Custom File Formats
	//	at	https://developer.apple.com/documentation/uikit/view_controllers/building_a_document_browser_app_for_custom_file_formats
	//
	//	shows how to configure a custom file format and provide thumbnails
	//	(and also "previews", which 4D Draw doesn't need).
	//	Best of all, that page provide a working Xcode project to use as a model.
	//
	//	The page
	//
	//		https://www.raywenderlich.com/5244-document-based-apps-tutorial-getting-started
	//
	//	also provides some instructions, but they may be less definitive.
	//
	//	Neither the Apple project nor the Ray Wenderlich project
	//	uses SwiftUI, but that may be irrelevant for creating thumbnails.
	
	//	In March 2021
	//
	//	Debugging thumbnails in 4D Draw is hit-or-miss.
	//	Sometimes breakpoints and print statements
	//	get respected, sometimes not.
	//
	//	When I try running the ThumbnailProvider
	//	for Apple's Particles sample code in the debugger,
	//	their ThumbnailProvider crashes entirely (!).
	
	override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {

		//	If in the future SwiftUI includes a concurrency-friendly
		//	async version of provideThumbnail, maybe I won't need
		//	this synchronous wrapper for a provideThumbnailAsync().

		//	Copy the relevant parameters, to avoid passing a reference
		//	to the QLFileThumbnailRequest into our async Task.
		//
		let theFileURL = request.fileURL
		let theMaximumSize = request.maximumSize
		let theScale = request.scale
		
		//	This provideThumbnail is called from synchronous code,
		//	but KaleidoPaintRenderer() is asynchronous.
		//	So we need to make the jump.
		//
		//	Swift 6 is unwilling to pass the handler into asynchronous code,
		//	to avoid the risk that we might access it here and in the Task
		//	at the same time. As a workaround, let's cast the handler
		//	to be nonisolated.
		//
		//	If Apple someday includes an async version of provideThumbnail,
		//	then our problems are solved. But this might not be a priority
		//	for Apple, if most developers can create thumbnails directly,
		//	in synchronous code, in contrast to our situation where our
		//	computation relies on a renderer that's MainActor-isolated.
		//
		nonisolated(unsafe) let theNonisolatedHandler = handler

		Task() { @Sendable in
		
			let (theThumbnailReply, theError) = await provideThumbnailAsync(
													fileURL: theFileURL,
													maximumSize: theMaximumSize,
													scale: theScale)

			theNonisolatedHandler(theThumbnailReply, theError)
		}
	}
}

nonisolated func provideThumbnailAsync(
	fileURL: URL,
	maximumSize: CGSize,
	scale: CGFloat
) async -> (QLThumbnailReply?, Error?) {

	//	Diagnostic code, to test whether the device is using
	//	the current version of the Draw4DThumbnailProvider
	//	(as opposed to whatever cached version it might have).
//		let theTestDrawingBlock = { () -> Bool in
//			let theColor = UIColor.red	//	change to color for subsequent tests
//			theColor.setFill()
//			let theRect = CGRect(origin: .zero, size: theMaximumSize)
//			UIRectFill(theRect)
//			return true
//		}
//		let theTestReply = QLThumbnailReply(
//			contextSize: theMaximumSize,
//			currentContextDrawing: theTestDrawingBlock)
//		handler(theTestReply, nil)
//		return

	//	Load the ModelData.

	let theDrawingAsUTF8EncodedData: Data
	do {
		theDrawingAsUTF8EncodedData = try Data(contentsOf: fileURL )
	} catch {
		return (nil, Draw4DThumbnailError.invalidFileURL)
	}

	guard let theDrawingAsText = String(
							data: theDrawingAsUTF8EncodedData,
							encoding: .utf8
	) else {
		return (nil, Draw4DThumbnailError.invalidUTF8Data)
	}
	
	let theDocument: Draw4DDocument
	do {
		theDocument = try await Draw4DDocument(drawingAsText: theDrawingAsText)
	} catch {
		return (nil, Draw4DThumbnailError.invalidDrawingData)
	}
	
	//	Suppress the box while making thumbnails.
	await theDocument.setBoxIsEnabled(false)

	//	Compute the size of the largest valid square image.

	let theMinDimensionPt = min(maximumSize.width,
								maximumSize.height)
	let theMinDimensionPx = theMinDimensionPt * scale
	let theContextSizePt = CGSize(	width: theMinDimensionPt,
									height: theMinDimensionPt)
	let theContextSizePx = CGSize(	width: theMinDimensionPx,
									height: theMinDimensionPx)

	//	Create a Draw4DRenderer.

	//	We must set isDrawingThumbnail = true for the following reason.
	//	---------------------------------------
	//	When QuickLook calls our callback code (theDrawingBlock - see below),
	//	it provides a CGContext with only a "narrow gamut" color space.
	//	If we rendered a Display P3 image, then when context.draw(in:)
	//	draws it, it would screw up the colors (more precisely,
	//	the specular highlights would all come out dark, and of the wrong hue).
	//	To avoid that, we must render a narrow-color thumbnail image.
	//
	//		Note:  The fragment function will nevertheless
	//		compute wide-gamut Extended Range sRGB colors,
	//		but Metal will clamp those colors to non-extended-range sRGB
	//		when it writes them into the framebuffer.
	//
	//	---------------------------------------
	guard let theRenderer = await Draw4DRenderer(isDrawingThumbnail: true)
	else {
		return (nil, Draw4DThumbnailError.failedToCreateMetalRenderer)
	}

	//	Ask theRenderer to create a screenshot for us.
	guard let theCGImage = await theRenderer.createOffscreenImage(
		modelData: theDocument,
		widthPx:  Int(theContextSizePx.width ),
		heightPx: Int(theContextSizePx.height),
		transparentBackground: true,
		extraRenderFlag: nil
	) else {
		return (nil, Draw4DThumbnailError.failedToRenderImage)
	}

	//	Later on, when QuickLook wants to draw the thumbnail,
	//	it will prepare a Core Graphics drawing context
	//	of size theContextSizePx (not theContextSizePt!)
	//	and call our code (theDrawingBlock).
	//
	//		Note:  QuickLook will set up the drawing context
	//		with Core Graphics' coordinate system,
	//		meaning that the origin at the lower left
	//		and the y axis is directed upward.
	//
	let theDrawingBlock = { (context: CGContext) -> Bool in

		//	Diagnostic code to check whether the context
		//	supports wide-gamut color or not.
		//	iOS 14.4.1, running on my iPhone SE (2020)
		//	provides a narrow-gamut context, even though
		//	the phone has a Display P3 display.
		//
		//		if let theColorSpace = context.colorSpace {
		//			print(theColorSpace.isWideGamutRGB ? "wide gamut" : "narrow gamut")
		//		} else {
		//			print("no color space")
		//		}
		//

		//	Draw the screenshot in the graphics context
		//	that QuickLook as prepared for us.
		//
		//		Note:  draw(in:) draws theCGImage
		//		"in current user space coordinates",
		//		which empirically seem to be points, not pixels.
		//		So to fill the available space, we must
		//		pass theContextSizePx, not theContextSizePt.
		//
		context.draw(
			theCGImage,
			in: CGRect(origin: .zero, size: theContextSizePx))

		//	Return true iff the thumbnail was
		//	successfully drawn inside this block.
		return true
	}

	let theReply = QLThumbnailReply(
		contextSize: theContextSizePt,	//	in points, not pixels
		drawing: theDrawingBlock)		//	uses pixels, not points

	return (theReply, nil)
}
