--[[----------------------------------------------------------------------------

ADOBE CONFIDENTIAL
------------------
Copyright 2025 Adobe
All Rights Reserved.

NOTICE: All information contained herein is, and remains
the property of Adobe and its suppliers, if any. The intellectual
and technical concepts contained herein are proprietary to Adobe
and its suppliers and are protected by all applicable intellectual
property laws, including trade secret and copyright laws.
Dissemination of this information or reproduction of this material
is strictly forbidden unless prior written permission is obtained
from Adobe.

------------------------------------------------------------------------------]]

	-- Rx
local Rx = require 'Rx'
local Observable = Rx.Observable

	-- WF.core.utils
local KSData = require 'KSData'
local KSDate = require 'KSDate'
local KSIdGenerator = require 'KSIdGenerator'
local KSPathUtils = require 'KSPathUtils'
local KSTableUtils = require 'KSTableUtils'
local WFArguments = require 'WFArguments'
local WFImsAppIdentity = require 'WFImsAppIdentity'
local WFFileUtils = require 'WFFileUtils'
local WFLogger = require 'WFLogger'

	-- WF.web
local KSJsonDecoder = require 'KSJsonDecoder'
local KSJsonEncoder = require 'KSJsonEncoder'
local WFRxHttpClient = require 'WFRxHttpClient'

--------------------------------------------------------------------------------

local logger = WFLogger( 'WFCatOrbit' )

local check = assert( WFArguments.check )

local KSTableUtils_derefOpt = KSTableUtils.derefOpt

--------------------------------------------------------------------------------

-- Sensei

local kSenseiV2Predict_Stage = "https://sensei-stage-ue1.adobe.io/services/v2/predict"
local kSenseiV2Predict_Prod = "https://sensei-ue1.adobe.io/services/v2/predict"

-- Autotag Service Constants

local kAutoTagServiceUrl_Prod = kSenseiV2Predict_Prod
local kAutotagServiceName = 'Classification:autotags:Service-011d3ac6049246c9b54d2bcd456c1264'
local kAutotagServiceTagger = 'tagger'
local kAutotagServiceCategory = 'category'
local kAutotagMaxResultsDefault = 100

-- Firefly Service Constants

local kFireflyServiceUrl_Prod = "https://firefly.adobe.io/spl"
local kFireflyServiceUrl_Stage = "https://firefly-stage.adobe.io/spl"
local kOutpaintingServiceGraph = 'urn:graph:MultiDiffusion_outpaint_v3'
local kOutpaintingModeGoup = 'goup' -- generic outpainting
local kOutpaintingModeToup = 'toup' -- text-guided outpainting

-- MetaCAF Service Constants

local kDIIMetaCAFUrl_Stage = kSenseiV2Predict_Stage
local kDIIMetaCAFUrl_Prod = kSenseiV2Predict_Prod
local kDIIMetaCAFServiceName = "Feature:autocrop:Service-f0372bb81536409598142277cbb2bf34"
local kDIIMetaCAFServiceId = "Service-f0372bb81536409598142277cbb2bf34"

-- DII SSM CPF Service Constants

local kDIIServerSideMaskingUrl_Stage = kSenseiV2Predict_Stage
local kDIIServerSideMaskingUrl_Prod = kSenseiV2Predict_Prod
local kDIIServerSideMaskingServiceName = "Classification:autocrop:Service-e02a518da69e44c7a90ea5f1006a02bd"

-- GPT Adobe Research Endpoint

local kGPTEndpoint = 'https://photos-stage.adobe.io/v2/assistant/gpt/answer'

-- Adobe S3 Storage Utils Endpoint

local kS3StorageEndpoint = 'https://image.adobe.io/utils/storage'

--============================================================================--

local WFCatOrbit = {}

--------------------------------------------------------------------------------
--@mark Sensei Prediction Services
--------------------------------------------------------------------------------

--[[
	The following code was written to explore Sensei integrations within WF.
	It is unused, and outside of the unit tests, it is untested. It can be
	used as a reference, or for further experimentation.
]]--

local function requestAutotagsForImage( authToken, filePath, autotagService, maxResults )

	-- https://wiki.corp.adobe.com/pages/viewpage.action?pageId=2269164104

	logger:infof( "requestAutotagsForImage filePath: %s, autotagService %s", filePath, autotagService )
 
	autotagService = autotagService or kAutotagServiceTagger

	maxResults = maxResults or kAutotagMaxResultsDefault

	if not filePath or not WFFileUtils.exists( filePath ) then

		return Observable.throw(
				string.format( "The specified file does not exist: %s", filePath ) )

	end

	local imsClientId = WFImsAppIdentity.getClientId()

	local requestHeaders = {
		{
			header = 'X-Api-Key',
			value = imsClientId
		},
		{
			header = 'Prefer',
			value = 'respond-sync, wait=1'
		},
		{
			header = 'Cache-Control',
			value = 'no-cache'
		},
		{
			header = 'Authorization',
			value = string.format( "Bearer %s", authToken )
		}
	}

	local requestOptions = {
		requestMethod = 'POST',
		returnHttpErrorsAsData = true,
		requestHeaders = requestHeaders,
		withoutAdobeAuthorization = true,
		multipartRequest = true,
		requestCost = WFRxHttpClient.requestCostUploadNegative,
		requestPriority = WFRxHttpClient.requestPriorityHighest
	}

	local contentAnalyzerRequests = {
		[ "sensei:invocation_mode" ] = "synchronous",
		[ "sensei:invocation_batch" ] = false,
		[ "sensei:name" ] = kAutotagServiceName,
		[ "sensei:engines" ] = {
			{
				[ "sensei:execution_info" ] = {
					[ "sensei:engine" ] = kAutotagServiceName
				},
				[ "sensei:params" ] = {
					[ "num_search_hits" ] = maxResults,
					[ "service" ] = autotagService
				},
				[ "sensei:inputs" ] = {
					[ "image_input"] = {
						[ "dc:format" ] = "image/jpg",
						[ "sensei:multipart_field_name" ] = "infile"
					}
				},
				[ "sensei:outputs" ] = {
					[ "result" ] = {
						[ "dc:format" ] = "application/json",
						[ "sensei:multipart_field_name" ] = "classifications"
					}
				}
			}
		}
	}

	local fileName = KSPathUtils.leafName( filePath )

	local multipartRequestData = {
		{
			name = 'contentAnalyzerRequests',
			contentType = 'application/json',
			value = KSJsonEncoder.prettyPrint( contentAnalyzerRequests, { pretty = true } )
		},
		{
			name = 'infile',
			filePath = filePath,
			fileName = fileName
		}
	}

	return WFRxHttpClient.put( kAutoTagServiceUrl_Prod, multipartRequestData, requestOptions )

end

--------------------------------------------------------------------------------

--[[!

	\brief Fetches autotags from Sensei for a specified image.

	\param authToken (string) The authorization token needed to communicate with
		the sensei service.
	\param filePath (string) A file path to the image.
	\param maxResults (?number) The number of autotags the service should generate
		and return. Defaults to 100.

	\return Observable which fires with the HTTP response from the service.
		See https://wiki.corp.adobe.com/pages/viewpage.action?pageId=2269164104 for
		more information on the response.

--]]

function WFCatOrbit.requestAutotagsForImage_experimental( authToken, filePath, maxResults )

	check( "+string", "+string", '?number' )

	return requestAutotagsForImage( authToken, filePath, kAutotagServiceTagger, maxResults )

end

--------------------------------------------------------------------------------

--[[!
	\brief Fetches categorization autotags from Sensei for the specified file.

	\param authToken (string) The authorization token needed to communicate with
		the sensei service.
	\param filePath (string) A file path to the image.
	\param maxResults (?number) The number of autotags the service should generate
		and return. Defaults to 100.

	\return Observable which fires with the HTTP response from the service.
--]]

function WFCatOrbit.requestCategoryAutotagsForImage_experimental( authToken, filePath, maxResults )

	check( "+string", "+string", '?number' )

	return requestAutotagsForImage( authToken, filePath, kAutotagServiceCategory, maxResults )

end

--------------------------------------------------------------------------------
--@mark Firefly Service
--------------------------------------------------------------------------------

local readyForRandomness = false

local function prepRandomness()

	if not readyForRandomness then
	
		-- Produces a different sequence each time (assuming enough time between invocations)
	
		math.randomseed( os.time() )
		math.random()
		math.random()
		math.random()
		
		readyForRandomness = true
	
	end

end

--------------------------------------------------------------------------------

local function getSeedValue()

	-- Generate a random seed value. Max and Min values are defined by the service.

	local min = 0
	local max = 2147483647

	prepRandomness()

	local random = math.random( min, max - 1 )

	return random

end

--------------------------------------------------------------------------------

local function getSeedArray( count )

	local seeds = {}

		for i = 0, count - 1, 1 do

		local seedInfo = {
			name = tostring( i ),
			type = 'scalar',
			value = getSeedValue()
		}

		seeds[ #seeds + 1 ] = seedInfo

	end
	
	return seeds

end

--------------------------------------------------------------------------------

local sessionId = KSIdGenerator.uuidInLowercaseHexWithHyphens()
	-- https://lightroom.slack.com/archives/C04DUFT283H/p1684816671093209?thread_ts=1684815815.573139&cid=C04DUFT283H
	-- Clients must generate a random guid when a firefly session starts and use
	-- that same guid for as long as the session is active in the app.
	-- It is highly discouraged to create a new session id for every request as
	-- gaining a new session may be restricted if the service is above capacity.
	-- So reusing a session for as long as possible is best practice.

local function fireflyOutpaintingRequest( wfCatalog, filePath, size, optArgs )

	-- See https://wiki.corp.adobe.com/display/DISysEng/MultiDiffusion+Service#MultiDiffusionService-Outpainting(withAspectRatio)
	-- for more details on outpainting parameters, and requests/responses.

	logger:infof( "fireflyOutpaintingRequest filePath: %s, size %s, optArgs %s", filePath, size, optArgs )

	if not filePath or not WFFileUtils.exists( filePath ) then

		return Observable.throw(
				string.format( "The specified file does not exist: %s", filePath ) )

	end

	-- Prompt Processing

	local prompt = optArgs.prompt

	local mode = kOutpaintingModeGoup

	local maybePromptParam

	if prompt then
	
		mode = kOutpaintingModeToup
		
		maybePromptParam = {
			[ 'name' ] = "gi_PROMPT",
			[ 'value' ] = prompt,
			[ 'type' ] = "string"
		}
	
	end

	-- Form Firefly Request

	local request = {
	
		[ 'graph' ] = {
			[ 'uri' ] = kOutpaintingServiceGraph
		},
		
		[ 'params' ] = {
			{
				[ 'name' ] = "gi_NUM_STEPS",
				[ 'value' ] = 40, -- Number of sampling steps
				[ 'type' ] = "scalar"
			},
			{
				[ 'name' ] = "gi_MODE",
				[ 'value' ] = mode,
				[ 'type' ] = "string"
			},
			{
				[ 'name' ] = "gi_AR_SHIFT",
				[ 'value' ] = optArgs.shift or 0, -- Shift position for outpainting
				[ 'type' ] = "scalar"
			},
			{
				[ 'name' ] = "gi_AR_DILATE",
				[ 'value' ] = optArgs.dilate or 0, -- Dilate mask to have better boundary
				[ 'type' ] = "scalar"
			},
			{
				[ 'name' ] = "gi_SEED",
				[ 'value' ] = getSeedArray( 4 ),
				[ 'type' ] = "array"
			},
			{
				[ 'name' ] = "gi_OUTPUT_HEIGHT",
				[ 'value' ] = size.height,
				[ 'type' ] = "scalar"
			},
			{
				[ 'name' ] = "gi_OUTPUT_WIDTH",
				[ 'value' ] = size.width,
				[ 'type' ] = "scalar"
			},
			{
				[ 'name' ] = "gi_AR_CUSTOM_H",
				[ 'value' ] = size.height,
				[ 'type' ] = "scalar"
			},
			{
				[ 'name' ] = "gi_AR_CUSTOM_W",
				[ 'value' ] = size.width,
				[ 'type' ] = "scalar"
			},
			maybePromptParam
		},

		[ 'inputs' ] = {
			[ 'gi_IMAGE' ] = {
				[ 'id' ] = KSIdGenerator.uuidInLowercaseHexWithHyphens(),
				[ 'type' ] = "image",
			}
		},

		[ 'outputs' ] = {
			[ 'gi_GEN_IMAGE' ] = {
				[ 'id' ] = KSIdGenerator.uuidInLowercaseHexWithHyphens(),
				[ 'type' ] = "array",
				[ 'expectedMimeType' ] = "image/jpeg",
				[ 'expectedArrayLength' ] = 4,
			},
			[ 'gi_GEN_STATUS' ] = {
				[ 'id' ] = KSIdGenerator.uuidInLowercaseHexWithHyphens(),
				[ 'type' ] = "array"
			}
		}
	}

	local requestOptions = {
		requestMethod = 'POST',
		returnHttpErrorsAsData = true,
		multipartRequest = true,
		requestCost = WFRxHttpClient.requestCostUploadNegative,
		requestPriority = WFRxHttpClient.requestPriorityHighest,
		requestHeaders = {
			{
				header = 'x-session-id',
				value = sessionId,
			}
		}
	}

	local fileName = KSPathUtils.leafName( filePath )

	local multipartRequestData = {
		{
			name = 'request',
			contentType = 'application/json',
			value = KSJsonEncoder.prettyPrint( request, { pretty = true } )
		},
		{
			name = 'gi_IMAGE',
			filePath = filePath,
			fileName = fileName
		}
	}

	local serverEndpoint = optArgs.prod and kFireflyServiceUrl_Prod or kFireflyServiceUrl_Stage

	local wfCatSpec = wfCatalog:ozCatalogSpec()

	return wfCatSpec:put( serverEndpoint, multipartRequestData, requestOptions )

end

--------------------------------------------------------------------------------

--[[!

	\brief Uses the Firefly outpainting service to expand the specified image.

	\param wfCatalog (WFCatalog)

	\param filePath (string) A file path to the image.

	\param size (table) A table that contains the new output width and height:
		{
			width = <number>
			height = <number
		}

	\param optArgs (?table) Optional arguments to modify the outpainting request:

		prod (?boolean): If true, will issue the request against prod. Defaults to stage.
		prompt (?string): Text-input to guide the firefly output.
		shift (?number): Shift position for outpainting. Must be from -1 to 1. Default is 0.
		dilate (?number): Dilate mask to have better boundary. Must be from 0.0 to 0.5 Default is 0.

	\return Observable which fires with the HTTP response from the service.
		See https://wiki.corp.adobe.com/display/DISysEng/MultiDiffusion+Service#MultiDiffusionService-Outpainting(withAspectRatio)
		for more information on the response.

--]]

function WFCatOrbit.requestExpand( wfCatalog, filePath, size, optArgs )

	check( "WFCatalog", "+string", "table", "?table" )
	
	optArgs = optArgs or {}

	return fireflyOutpaintingRequest( wfCatalog, filePath, size, optArgs )

end

--------------------------------------------------------------------------------
--@mark DII MetaCAF
--------------------------------------------------------------------------------

local function getHoleClassification( value )

	local classification = 'CAF'

	if value == 1 then
		classification = 'SuperCAF'
	elseif value == 2 then
		classification = 'CMGAN'
	elseif value == 3 then
		classification = 'Clio-MD'
	end

	return classification

end

--------------------------------------------------------------------------------

local outputContentTypes = {
	[ 'application/octet-stream' ] = true,
	[ 'image/png' ] = true,
	[ 'image/jpg' ] = true
}

--------------------------------------------------------------------------------

local function getDCFormat( extn )
	return string.format( "image/%s", extn )
end

--------------------------------------------------------------------------------

local function getLCFileExtension( path )

	local _, _, extn = string.find( path, ".+%.([^.]+)" )
	extn = string.lower( extn )

	return extn

end

--------------------------------------------------------------------------------

local function performMetaCAFRequest( wfCatalog, imagePath, maskPath, optArgs )

	-- See https://wiki.corp.adobe.com/display/DISysEng/MetaCAF+Service

	logger:infof( "performMetaCAFRequest inputImage: %s, inputMask, %s", imagePath, maskPath )

	if not imagePath or not WFFileUtils.exists( imagePath ) then

		return Observable.throw(
				string.format( "The specified input image does not exist: %s", imagePath ) )

	end

	if not maskPath or not WFFileUtils.exists( maskPath ) then

		return Observable.throw(
				string.format( "The specified input mask does not exist: %s", maskPath ) )

	end

	local requestHeaders = {
		{
			header = 'X-Api-Key',
			value = 'AdobeSenseiPredictServiceProdKey' -- FIXME eventually.
		},
		{
			header = 'X-analyzer-id',
			value = kDIIMetaCAFServiceId
		},
		{
			header = 'Prefer',
			value = 'respond-sync, wait=60'
		},
		{
			header = 'Cache-Control',
			value = 'no-cache'
		},
	}

	local requestOptions = {
		requestMethod = 'POST',
		returnHttpErrorsAsData = true,
		requestHeaders = requestHeaders,
		multipartRequest = true,
		requestCost = WFRxHttpClient.requestCostUploadNegative,
		requestPriority = WFRxHttpClient.requestPriorityHighest
	}

	local inputImageExtn = getLCFileExtension( imagePath )
	local inputMaskExtn = getLCFileExtension( maskPath )

	local inputFormat = getDCFormat( inputImageExtn )
	local maskFormat = getDCFormat( inputMaskExtn )
	local outputFormat = inputImageExtn == 'jxl' and "image/jxl" or "image/png"

	local contentAnalyzerRequests = {
		[ "sensei:name" ] = "MetaCAF",
		[ "sensei:invocation_mode" ] = "synchronous",
		[ "sensei:invocation_batch" ] = false,
		[ "sensei:in_response" ] = false,
		[ "sensei:engines" ] = {
			{
				[ "sensei:execution_info" ] = {
					[ "sensei:engine" ] = kDIIMetaCAFServiceName
				},
				[ "sensei:params" ] = {},
				[ "sensei:inputs" ] = {
					[ "input"] = {
						[ "dc:format" ] = inputFormat,
						[ "sensei:multipart_field_name" ] = "input_image"
					},
					[ "mask"] = {
						[ "dc:format" ] = maskFormat,
						[ "sensei:multipart_field_name" ] = "input_mask"
					}
				},
				[ "sensei:outputs" ] = {
					[ "spl:response" ] = {
						[ "dc:format" ] = "application/json",
						[ "sensei:multipart_field_name" ] = "metadata"
					},
					[ "output" ] = {
						[ "dc:format" ] = outputFormat,
						[ "sensei:multipart_field_name" ] = "output_image"
					},
				}
			}
		}
	}

	local multipartRequestData = {
		{
			name = 'contentAnalyzerRequests',
			contentType = 'application/json',
			value = KSJsonEncoder.prettyPrint( contentAnalyzerRequests, { pretty = true } )
		},
		{
			name = 'input_image',
			filePath = imagePath,
			fileName = KSPathUtils.leafName( imagePath )
		},
		{
			name = 'input_mask',
			filePath = maskPath,
			fileName = KSPathUtils.leafName( maskPath )
		}
	}

	local wfCatSpec = wfCatalog:ozCatalogSpec()

	local endpoint = optArgs.stage and kDIIMetaCAFUrl_Stage or kDIIMetaCAFUrl_Prod

	return wfCatSpec:put( endpoint, multipartRequestData, requestOptions )

		:map( function( parts, headers )

			local statusCode = KSTableUtils_derefOpt( headers, 'status_code' )

			if statusCode == '200' then

				for i, part in ipairs( parts ) do

					local partHeaders = KSTableUtils_derefOpt( part, 'headers' )

					local partData = KSTableUtils_derefOpt( part, 'data' )

					local holeClassificationInt =  KSTableUtils_derefOpt( partData, 'hole_classification' )

					if holeClassificationInt then

						local classification = getHoleClassification( holeClassificationInt )
						logger:infof( "Hole Classification: %s", classification )

					end

					local partHeadersContentType = KSTableUtils_derefOpt( partHeaders, 'Content-Type' )

					if outputContentTypes[ partHeadersContentType ] then

						return partData

					end

				end

			end

			return error( "The DII MetaCAF request could not be fulfilled. Status code: %s", statusCode )

		end )

end

--------------------------------------------------------------------------------

--[[!

	\brief Fills a hole using DII MetaCAF

	\param wfCatalog (WFCatalog)
	\param imagePath (string) A file path to the input image.
	\param maskPath (string) A file path to the mask.
	\param optArgs (optional table) Optional parameters to modify the request:
		{
			stage (boolean): If true, the request will run against stage.
		}

	\return Observable which fires with generated image data. Throws an error
		if the operation fails.

--]]

function WFCatOrbit.requestFillHoleWithMetaCAF_experimental( wfCatalog, imagePath, maskPath, optArgs )

	check( "WFCatalog", "+string", "+string", "?table" )

	optArgs = optArgs or {}

	return performMetaCAFRequest( wfCatalog, imagePath, maskPath, optArgs )

end

--------------------------------------------------------------------------------
--@mark DII SSM
--------------------------------------------------------------------------------

local function performServerSideMaskingRequest( wfCatalog, imagePath, optArgs )

	-- See https://wiki.corp.adobe.com/pages/viewpage.action?pageId=2947463333

	logger:infof( "performServerSideMaskingRequest inputImage: %s", imagePath )

	if not imagePath or not WFFileUtils.exists( imagePath ) then

		return Observable.throw(
				string.format( "The specified input image does not exist: %s", imagePath ) )

	end

	local imsClientId = WFImsAppIdentity.getClientId()

	local requestHeaders = {
		{
			header = 'Prefer',
			value = 'respond-sync, wait=60'
		},
		{
			header = 'Cache-Control',
			value = 'no-cache'
		},
		{
			header = 'X-Api-Key',
			value = imsClientId
		},
	}

	local requestOptions = {
		requestMethod = 'POST',
		returnHttpErrorsAsData = true,
		requestHeaders = requestHeaders,
		multipartRequest = true,
		requestCost = WFRxHttpClient.requestCostUploadNegative,
		requestPriority = WFRxHttpClient.requestPriorityHighest
	}

	local contentAnalyzerRequests = {
		[ "sensei:name" ] = "ACR Masking",
		[ "sensei:invocation_mode" ] = "synchronous",
		[ "sensei:invocation_batch" ] = false,
		[ "sensei:in_response" ] = false,
		[ "sensei:engines" ] = {
			{
				[ "sensei:execution_info" ] = {
					[ "sensei:engine" ] = kDIIServerSideMaskingServiceName
				},
				[ "sensei:params" ] = {
					 [ "request_types" ] = {
						"selectSubject",
						"selectSky",
						"selectSkin"
					}
				},
				[ "sensei:inputs" ] = {
					[ "image"] = {
						[ "dc:format" ] = "image/jpeg",
						[ "sensei:multipart_field_name" ] = "infile"
					}
				},
				[ "sensei:outputs" ] = {
					[ "masks" ] = {
						[ "dc:format" ] = "image/tiff",
						[ "sensei:multipart_field_name" ] = "outfiles"
					},
					[ "metadata" ] = {
						[ "dc:format" ] = "application/json",
						[ "sensei:multipart_field_name" ] = "metadata"
					}
				}
			}
		}
	}

	local multipartRequestData = {
		{
			name = 'contentAnalyzerRequests',
			contentType = 'application/json',
			value = KSJsonEncoder.prettyPrint( contentAnalyzerRequests, { pretty = true } )
		},
		{
			name = 'infile',
			filePath = imagePath,
			fileName = KSPathUtils.leafName( imagePath )
		}
	}

	-- Prep Output Folder

	local parentPath = KSPathUtils.parent( imagePath )
	local resultFolder = KSPathUtils.child( parentPath, "result" )
	WFFileUtils.mkdir( resultFolder )

	-- Issue the Request

	local wfCatSpec = wfCatalog:ozCatalogSpec()

	local endpoint = optArgs.stage and kDIIServerSideMaskingUrl_Stage or kDIIServerSideMaskingUrl_Prod

	return wfCatSpec:put( endpoint, multipartRequestData, requestOptions )

		:flatMap( function( parts, headers )

			local statusCode = KSTableUtils_derefOpt( headers, 'status_code' )

			local contentTypeStr = KSTableUtils_derefOpt( headers, 'Content-Type' )
			local contentType = contentTypeStr:match( "([^;]+)" ) -- Use only left-most content string.

			if statusCode == '200' and type( parts ) == 'table' then

				return Observable.from( parts )

			elseif contentType == 'application/json' then

				local response = KSJsonDecoder.decodeWebSafeJson( parts )

				statusCode = KSTableUtils_derefOpt( response, 'statuses', 1, 'invocations', 1, 'status' )
				local statusMessage = KSTableUtils_derefOpt( response, 'statuses', 1, 'invocations', 1, 'message' )

				return Observable.throw(
						string.format( "The masking request could not be fulfilled. Status code: %s, %s",
								statusCode, statusMessage ) )

			end

			return Observable.throw(
					string.format( "The masking request could not be fulfilled. Status code: %s",
							statusCode ) )

		end )

		:map( function( part )

			-- The id field of each returned object serves as a mapping to
			-- the filename of the corresponding mask file in the multipart
			-- response.

			local partHeaders = KSTableUtils_derefOpt( part, 'headers' )

			local partHeadersContentType = KSTableUtils_derefOpt( partHeaders, 'Content-Type' )

			if outputContentTypes[ partHeadersContentType ] then

				local partData = KSTableUtils_derefOpt( part, 'data' )

				local maskName = KSIdGenerator.uuidInLowercaseHex()

				local maskPath = KSPathUtils.child( resultFolder, maskName )

				local maskData = KSData.make( partData )
				KSData.saveToFile( maskData, maskPath )
				maskData:close()

				return maskPath

			end

			return nil

		end )

		:scan( function( paths, maskPath )

			if maskPath then
				paths[ #paths + 1 ] = maskPath
			end

			return paths

		end, {} )

		:takeLast( 1 )

end

--------------------------------------------------------------------------------

--[[!

	\brief Server Side Masking

	\param wfCatalog (WFCatalog)
	\param imagePath (string) A file path to the input image.
	\param optArgs (optional table) Optional parameters to modify the request:
		{
			stage (boolean): If true, the request will run against stage.
		}

	\return Observable which fires with the file paths of the generated masks.

--]]

function WFCatOrbit.requestServerSideMasking_experimental( wfCatalog, imagePath, optArgs )

	check( "WFCatalog", "+string", "?table" )

	optArgs = optArgs or {}

	return performServerSideMaskingRequest( wfCatalog, imagePath, optArgs )

end

--------------------------------------------------------------------------------
--@mark ChatGPT
--------------------------------------------------------------------------------

local function sampleRenditionUrls( albumRenditionInfo )

	-- Sample random URLs from albumRenditionUrls

	local imageUrlTemplate = [[
	{
		"type": "image_url",
		"image_url": {
			"url": "%s",
			"detail": "low"
		}
	}
	]]

	local imageUrls = {}
	local imageUrlsSubset1 = {}
	local imageUrlsSubset2 = {}

	local metadataStr = ""

	for i, renditionInfo in ipairs( albumRenditionInfo ) do

		local formattedUrl = string.format( imageUrlTemplate, renditionInfo.s3Url  )

		local hasPeople = renditionInfo.people and #renditionInfo.people > 0

		local peopleStr = hasPeople and table.concat( renditionInfo.people, ", " ) or "None"

		metadataStr = metadataStr .. string.format( "[Image %d URL: %s, People in Image %d: %s]%s",
				i, renditionInfo.s3Url, i, peopleStr, i < #albumRenditionInfo and ", " or "" )

		table.insert( imageUrls, formattedUrl )

		-- Split the sampled URLs into two subsets for the prompt.
		-- These are used to make backup requests, in case the request fails.

		if i % 2 == 1 then
			table.insert( imageUrlsSubset1, formattedUrl )
		else
			table.insert( imageUrlsSubset2, formattedUrl )
		end

	end

	local imageUrlsStr = table.concat( imageUrls, ",\n" )
	local imageUrlsSubset1Str = table.concat( imageUrlsSubset1, ",\n" )
	local imageUrlsSubset2Str = table.concat( imageUrlsSubset2, ",\n" )
	
	return imageUrlsStr, imageUrlsSubset1Str, imageUrlsSubset2Str, metadataStr

end

--------------------------------------------------------------------------------

local function convertGPSCoordinatesToString( gpsCoordinates )

	-- Convert GPS coordinates to GPS string: [latitude, longitude], [latitude, longitude], ...

	local gpsCoordinatesStr = ""

	if gpsCoordinates then

		local gpsStrs = {}

		for _, gps in ipairs( gpsCoordinates ) do

			local gpsStr = string.format( "[%s, %s]", gps[ 1 ], gps[ 2 ] )
			table.insert( gpsStrs, gpsStr )

		end

		gpsCoordinatesStr = table.concat( gpsStrs, ", " )

	end
	
	return gpsCoordinatesStr
	
end

--------------------------------------------------------------------------------

local function performRequestAlbumNameFromChatGPT( wfCatalog, earliestDate, latestDate, gpsCoordinates, albumRenditionInfo, primaryLocation, peopleCatInfo )

	-- FIXME [jandrew 2025-04-28]: To use this function, you must copy the LLM prompts
	-- from oz_client/managed_catalog/ml/WFCatOrbitPrompts.md into this file. We do
	-- not include them directly in this file, as they must be encrypted. Once we
	-- have a WFCrypto utility for this, we can include the encrypted strings directly
	-- in this file.

	local requestHeaders = {
		{
			header = 'X-Api-Key',
			value = "LightroomMobileOz1" -- FIXME: Only mobile is provisioned correctly for this API.
		},
		{
			header = 'Content-Type',
			value = "application/json"
		},
	}

	local requestOptions = {
		requestMethod = 'POST',
		returnHttpErrorsAsData = true,
		requestHeaders = requestHeaders,
		requestCost = WFRxHttpClient.requestCostLow,
		requestPriority = WFRxHttpClient.requestPriorityHigh,
		timeout = 15,
	}

	local imageUrlsStr, imageUrlsSubset1Str, imageUrlsSubset2Str, imageMetadataStr = sampleRenditionUrls( albumRenditionInfo )

	local gpsCoordinatesStr = convertGPSCoordinatesToString( gpsCoordinates )

	-- Album Name Formatting

	local albumNameFormatStr = "<PLACEHOLDER>"

	if primaryLocation or #gpsCoordinates == 0 then

		albumNameFormatStr = "<PLACEHOLDER>"

	end

	-- Format People Names & Counts, and add generate people-info strings.

	local peopleNamesAndCountsStr = ""

	if peopleCatInfo and next( peopleCatInfo ) then

		local peopleCatInfoStr = ""

		local namesAndCounts = {}

		for _, personInfo in pairs( peopleCatInfo ) do

			local personStr = string.format( "%s (%s)", personInfo.name, personInfo.count )
			namesAndCounts[ #namesAndCounts + 1 ] = personStr

		end

		peopleCatInfoStr = table.concat( namesAndCounts, ", " )

		peopleNamesAndCountsStr = string.format( "The images contain the following known people with associated counts: %s.", peopleCatInfoStr )

		local albumNameFormatPrefixStr = "<PLACEHOLDER>"

		albumNameFormatStr = string.format( "%s %s", albumNameFormatPrefixStr, albumNameFormatStr )

	end

	local promptStr = "<PLACEHOLDER>"

	local function createRequestData( imageUrlsStr )

		local data = string.format( [[
			{
				"prompt": [
					{
						"role": "system",
						"content": ]] .. promptStr .. [[
					},
					{
						"role": "user",
						"content": [
							{
								"type": "text",
								"text": "Analyze this sample of images with the following metadata: %s"
							},
							%s
						]
					}
				],
				"model": "gpt_4o_mini",
				"raw": true,
				"stream": false
			}
			]], earliestDate, latestDate,
			gpsCoordinatesStr, peopleNamesAndCountsStr,
			albumNameFormatStr, imageMetadataStr, imageUrlsStr )

		return data

	end

	-- Issue the Request

	local wfCatSpec = wfCatalog:ozCatalogSpec()

	-- We construct 3 separate requests, each of which samples a different subset
	-- of the rendition URLs. This is done to make the request more robust, such
	-- that if one request fails due to a bad rendition (which is fairly common),
	-- we have backup requests to fall back on.

	local dataForRequests = {
		createRequestData( imageUrlsStr ),
		createRequestData( imageUrlsSubset1Str ),
		createRequestData( imageUrlsSubset2Str )
	}

	return Observable.from( dataForRequests )

		:map( function( data )

			local startTime = KSDate.timeNowForAnimation()

			return wfCatSpec:put( kGPTEndpoint, data, requestOptions )

				:flatMap( function( response, headers )

					local statusCode = KSTableUtils_derefOpt( headers, 'status_code' )

					local contentTypeStr = KSTableUtils_derefOpt( headers, 'Content-Type' )
					local contentType = contentTypeStr:match( "([^;]+)" ) -- Use only left-most content string.

					if statusCode == '200' and contentType == 'application/json' then

						local endTime = KSDate.timeNowForAnimation()

						logger:infof( "GPT Request Time: %.3f seconds", endTime - startTime )

						-- This is hacky, but the response I get from GPT is not a standard JSON object.

						local responseStr = tostring( response )
						local luaResponse = KSJsonDecoder.decodeWebSafeJson( responseStr )
						luaResponse = KSJsonDecoder.decodeWebSafeJson( luaResponse )

						return Observable.just( luaResponse )

					end

					return Observable.just( false )

				end )

			end )

			:filter( function( hasResponse )

				return not not hasResponse

			end )

			:flatMerge( 1 )

			:take( 1 ) -- As soon as a single response is successful, we are done.

end

--------------------------------------------------------------------------------

--[[!

	\brief Requests an album name suggestion from ChatGPT based on photo metadata and content.

	\param wfCatalog (WFCatalog) The catalog instance to use for the request.
	\param earliestDate (string) The earliest date in the album in "YYYY-MM-DD" format.
	\param latestDate (string) The latest date in the album in "YYYY-MM-DD" format.
	\param gpsCoordinates (table) A list of GPS coordinates for photos in the album:
		{
			{ latitude (number), longitude (number) },
			...
		}
	\param albumRenditionInfo (table) A list of photo information:
		{
			{
				s3Url (string): URL of the photo,
				people (table): List of people in the photo
			},
			...
		}
	\param primaryLocation (boolean) Whether the album is considered a primary photography location.
	\param peopleCatInfo (table) A list of people categorization information:
		{
			{
				name (string): Name of the person,
				count (number): Number of photos containing this person
			},
			...
		}

	\return (Observable) An Observable that emits a table containing:
		{
			album_name (string): The suggested album name,
			explanation (string): The reasoning behind the suggestion,
			highlight_album (boolean): Whether the album should be highlighted,
			highlight_reason (string): The reason for highlighting or not
		}

--]]

function WFCatOrbit.requestAlbumNameFromChatGPT( wfCatalog, earliestDate, latestDate,
		gpsCoordinates, albumRenditionInfo, primarylocation, peopleCatInfo )

	check( "WFCatalog", "string", "string", "table", "table", "boolean", "?table" )

	return performRequestAlbumNameFromChatGPT(
			wfCatalog, earliestDate, latestDate, gpsCoordinates,
			albumRenditionInfo, primarylocation, peopleCatInfo )

end

--------------------------------------------------------------------------------

local function groupInputs( inputs, maxInputsPerGroup )

	local groups = {}
	local numGroups = math.ceil( #inputs / maxInputsPerGroup )

	for i = 1, numGroups do
		local startIdx = ( i - 1 ) * maxInputsPerGroup + 1
		local endIdx = math.min( i * maxInputsPerGroup, #inputs )

		local group = {}
		for j = startIdx, endIdx do
			group[ #group + 1 ] = inputs[ j ]
		end

		groups[ #groups + 1 ] = group
	end

	return groups

end

WFCatOrbit._DEBUG_groupInputs = groupInputs -- Exposed for testing

--------------------------------------------------------------------------------

--[[!

	\brief Performs the actual upload of a group of inputs to the storage service.

	\param wfCatalog (WFCatalog) The catalog instance to use for the upload.
	\param inputs (table) A list of inputs to upload, where each input is a table containing:
		{
			path (string): The file path of the input,
			length (number): The length of the input in bytes,
			id (string): A unique identifier for the input
		}

	\return (Observable) An Observable that emits a list of URL pairs for the uploaded inputs.
		Each URL pair contains the upload URL and the corresponding input information.

--]]

local function performUploadInputsToStorageService( wfCatalog, inputs )

	local requestHeaders = {
		{
			header = 'X-Api-Key',
			value = "LightroomAndroid2022" -- FIXME: Required for now.
		},
		{
			header = 'Content-Type',
			value = "application/json"
		}
	}

	local requestOptions = {
		requestMethod = 'GET',
		returnHttpErrorsAsData = true,
		requestHeaders = requestHeaders,
		requestCost = WFRxHttpClient.requestCostLow,
		requestPriority = WFRxHttpClient.requestPriorityHigh,
	}

	-- Issue the Request

	local wfCatSpec = wfCatalog:ozCatalogSpec()

	local numInputs = #inputs

	local endpointWithNum = string.format( "%s?num=%d", kS3StorageEndpoint, numInputs )

	return wfCatSpec:get( endpointWithNum, requestOptions )

		:flatMap( function( response, headers )

			local statusCode = KSTableUtils_derefOpt( headers, 'status_code' )

			if statusCode == '200' then

				local luaResponse = KSJsonDecoder.decodeWebSafeJson( response )

				local urls = KSTableUtils_derefOpt( luaResponse, "outputs", "urls" )

				for i, urlPair in ipairs( urls ) do
					urlPair.path = inputs[ i ].path
					urlPair.length = inputs[ i ].length
					urlPair.id = inputs[ i ].id
				end	

				return Observable.from( urls )

			end

			return Observable.throw( "The request to get the storage service URLs failed." )

		end )

		:map( function( urlPair )

			local inputUploadURL = KSTableUtils_derefOpt( urlPair, "put" )
			local inputFilePath = KSTableUtils_derefOpt( urlPair, "path" )
			local inputFileLength = KSTableUtils_derefOpt( urlPair, "length" )

			-- Upload the image to the storage service.

			local requestHeaders = {
				{
					header = 'Content-Type',
					value = 'image/jpeg'
				},
				{
					header = 'Content-Length',
					value = inputFileLength
				}
			}

			local requestOptions = {
				requestMethod = 'PUT',
				requestHeaders = requestHeaders,
				requestCost = WFRxHttpClient.requestCostLow,
				requestPriority = WFRxHttpClient.requestPriorityHigh,
				dataFormat = 'filePath',
				withoutAdobeAuthorization = true
			}

			return wfCatSpec:putBinary( inputUploadURL, inputFilePath, requestOptions )

				:map( function( data, headers )
					return urlPair
				end )

		end )

		:flatMerge( 3 )

end

--------------------------------------------------------------------------------

--[[!

	\brief Uploads a list of inputs to the storage service in groups.

	\param wfCatalog (WFCatalog) The catalog instance to use for the upload.
	\param inputs (table) A list of inputs to upload, where each input is a table containing:
		{
			path (string): The file path of the input,
			length (number): The length of the input in bytes,
			id (string): A unique identifier for the input
		}

	\return (Observable) An Observable that emits a single value containing a list of URLs
		for the uploaded inputs. The inputs are processed in groups of 5 to comply with
		the storage service's limitations.

--]]

function WFCatOrbit.uploadInputsToStorageService( wfCatalog, inputs )

	check( "WFCatalog", "table" )

	-- Split the inputs into groups of 5, as the storage service can only handle
	-- 5 inputs at a time.

	local maxInputsPerRequest = 5
	local inputGroups = groupInputs( inputs, maxInputsPerRequest )

	return Observable.from( inputGroups )

		:map( function( inputGroup )
			return performUploadInputsToStorageService( wfCatalog, inputGroup )
		end )

		:flatMerge( 1 ) -- Limit the number of concurrent requests to 1.

		:scan( function( allUrls, url )

			allUrls[ #allUrls + 1 ] = url
			return allUrls

		end, {} )

		:takeLast( 1 )

end

--------------------------------------------------------------------------------

local function performAskChatGPT( wfCatalog, query, optArgs )

	local requestHeaders = {
		{
			header = 'X-Api-Key',
			value = "LightroomMobileOz1" -- FIXME: Only mobile is provisioned correctly for this API.
		},
		{
			header = 'Content-Type',
			value = "application/json"
		},
	}

	local requestOptions = {
		requestMethod = 'POST',
		returnHttpErrorsAsData = true,
		requestHeaders = requestHeaders,
		requestCost = WFRxHttpClient.requestCostLow,
		requestPriority = WFRxHttpClient.requestPriorityHigh,
		timeout = 30,
	}

	local modelName = "gpt_4o_mini"

	if optArgs and optArgs.useFullModel then
		modelName = "gpt_4o"
	end

	local data = string.format( [[
		{
			"prompt": [
				{
					"role": "system",
					"content": "You are a helpful assistant for Adobe Lightroom users, with expertise in photography, photo editing, digital asset management, and metadata. Provide clear, concise, and accurate responses to questions related to these domains."
				},
				{
					"role": "user",
					"content": [
						{
							"type": "text",
							"text": "%s"
						}
					]
				}
			],
			"model": "%s",
			"raw": true,
			"stream": false
		}
		]], query, modelName )

	-- Issue the Request
	local wfCatSpec = wfCatalog:ozCatalogSpec()

	local startTime = KSDate.timeNowForAnimation()

	return wfCatSpec:put( kGPTEndpoint, data, requestOptions )

		:flatMap( function( response, headers )

			local statusCode = KSTableUtils_derefOpt( headers, 'status_code' )
			local contentTypeStr = KSTableUtils_derefOpt( headers, 'Content-Type' )
			local contentType = contentTypeStr and contentTypeStr:match( "([^;]+)" ) -- Use only left-most content string.

			if statusCode == '200' and contentType == 'application/json' then

				local endTime = KSDate.timeNowForAnimation()
				logger:infof( "ChatGPT Request Time: %.3f seconds (using %s)", endTime - startTime, modelName )

				local responseStr = tostring( response )

				return Observable.just( responseStr )
				
			end

			return Observable.throw( string.format( "Request failed with status code: %s", statusCode or "unknown" ) )

		end )
end

--------------------------------------------------------------------------------

--[[!
	\brief Sends a generic question to ChatGPT and returns the response.

	\param wfCatalog (WFCatalog) The catalog instance to use for the request.
	\param query (string) The question or prompt to send to ChatGPT.
	\param optArgs (table) Optional arguments:
		useFullModel (boolean): If true, uses the more powerful GPT-4o model instead of
		the faster, but less capable GPT-4o mini model. Default is false.

	\return (Observable) An Observable that emits a table containing the ChatGPT response.
		The specific structure of the response depends on the query and model used,
		but typically includes:
		{
			response (string): The text response from ChatGPT
		}
--]]

function WFCatOrbit.askChatGPT( wfCatalog, query, optArgs )

	check( "WFCatalog", "string", "?table" )

	return performAskChatGPT( wfCatalog, query, optArgs )

end

-- Expose helper functions for testing
WFCatOrbit._DEBUG_sampleRenditionUrls = sampleRenditionUrls
WFCatOrbit._DEBUG_convertGPSCoordinatesToString = convertGPSCoordinatesToString

--============================================================================--

return WFCatOrbit
