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

NmbCatPreset.lua

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

ADOBE CONFIDENTIAL
------------------
Copyright 2018 Adobe Systems Incorporated
All Rights Reserved.

NOTICE: All information contained herein is, and remains the property of
Adobe Systems Incorporated and its suppliers, if any. The intellectual and
technical concepts contained herein are proprietary to Adobe Systems
Incorporated 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 Systems Incorporated.

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

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

local KSData = require 'KSData'
local KSIdGenerator = require 'KSIdGenerator'
local KSPathUtils = require 'KSPathUtils'
local KSTableUtils = require 'KSTableUtils'
local KSStringUtils = require 'KSStringUtils'

local WFCrypto = require 'WFCrypto'
local WFAsyncFileUtils = require 'WFAsyncFileUtils'
local WFCatUtils = require 'WFCatUtils'
local NmbStyleFavorites = require 'NmbStyleFavorites'
local WFFileUtils = require 'WFFileUtils'
local WFTableMerge = require 'WFTableMerge'
local WFConfiguration = require 'WFConfiguration'
local WFRxRunUntilDone = require 'WFRxRunUntilDone'
local WFFolderWalker = require 'WFFolderWalker'
local WFXmp = require 'WFXmp'
local WFXMLNS = require 'WFXMLNS'
local WFCatFileSpec = require 'WFCatFileSpec'
local WFCatDelete = require 'WFCatDelete'
local WFLogger = require 'WFLogger'
local WFZString = require 'WFZString'

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

local PRESET_CONTENT_TYPE = "application/rdf+xml"
local PRESET_CREATE_BATCH_SIZE = 100

local logger = WFLogger( 'NmbCatPreset' )

local ZSTR = assert( WFZString.fromZString )

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

local NmbCatPreset = {}

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

local function formDocInfo( catalog, copiedFilePath, docValue, docValueIsOverlay )
	
	local volumeGuid = copiedFilePath and WFFileUtils.getVolumeGuidForPath( copiedFilePath )
	local relativeFilePath = copiedFilePath and WFFileUtils.removeVolumeFromPath( copiedFilePath )
	
	WFTableMerge.applyOverlay( docValue, {
		_localOnly = {
			files = {
				original = {
					master = {
						relativePath = relativeFilePath,
						volumeGuid = volumeGuid,
						sha256 = KSTableUtils.derefOpt( docValue, 'importSource', 'sha256' ),
						size = KSTableUtils.derefOpt( docValue, 'importSource', 'fileSize' ),
					}
				}
			},
		}
	} )

	catalog:getDeletionBacklog():removeFromDeletionBacklog( volumeGuid, relativeFilePath )
		-- If the file has been previously added to the deletion backlog, clear it out
		-- so we don't delete it.
		
	return docValue

end 

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

local function createPresetDocument( wfCatalog, name, groupName, path, isLook, maybeId, xmpStr, digest )

	local xmpString
	
	if xmpStr then
		xmpString = tostring( xmpStr )
	else
		local data = KSData.memoryMappedContentsOfFile( path )
		xmpString = tostring( data )
		data:close()
	end
	
	local subtype = 'preset'
	if isLook then
		subtype = 'profile'
	end
	
	-- FIXME  [jandrew 2019-04-24]: If the preset name is not valid UTF8, we instead may want
	-- to prevent the preset from being imported instead of changing the name.
	
	if not KSStringUtils.validateUTF8( name ) then
		name = ZSTR "$$$/NmbCatPreset/InvalidPresetName=Invalid Preset Name"
	end
	
	local presetDoc = {

		_id = maybeId or KSIdGenerator.uuidInLowercaseHex(),
		
		type = 'asset',
		subtype = subtype,
		name = name,
		group = groupName,
		
		importSource = {
			fileSize = WFFileUtils.fileAttributes( path ).fileSize,
			sha256 = WFCrypto.SHA256.digest( xmpString ),
			contentType = PRESET_CONTENT_TYPE,
		},
	}
	
	-- If a digest is available for a preset, add it to the doc
	if not isLook and digest then
		presetDoc.digest = digest
	end

	presetDoc = WFCatUtils.addUserCreatedFields( presetDoc )
	
	presetDoc = formDocInfo( wfCatalog, path, presetDoc, false )

	return presetDoc

end

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

--local function copyToLocalDirectory( wfCatalog, path, id )
--	
--	local catalogDir = KSPathUtils.child( getRootDir(), wfCatalog:ozCatalogId() )
--	local presetDir = KSPathUtils.child( catalogDir, "cr_settings" )
--	-- Ensure directory exists; might not if this is an initial
--	-- sync.
--	WFFileUtils.mkdir( presetDir )
--	
--	local presetDstFilePath = KSPathUtils.child( presetDir, id .. ".xmp" )
--	
--	return WFAsyncFileUtils.copyToUniquelyNamedFile( path, presetDstFilePath ) 
--end

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

local function renameFileWithOzId( wfCatalog, styleSynchronizer, path, id )

	local dir = KSPathUtils.parent( path )
	local newPath = KSPathUtils.child( dir, string.format( "%s.xmp", id ) )
	
	return WFAsyncFileUtils.moveToUniquelyNamedFile( path, newPath )
		
end

--------------------------------------------------------------------------------
--[[!
	\brief Adds an array of presets from disk to the catalog.
	
	\param wfCatalog (WFCatalog) catalog to receive the images
	\param presetArray (array of strings) paths of files to add
	
	\return Observable sends onCompleted when done
]]--

function NmbCatPreset.addPresetArrayToCatalog( wfCatalog, presetArray, styleSynchronizer )

	local needsRefresh = false
	
    return Observable.from( presetArray )
        :flatMap( function ( info )
			local path = info.filePath
			local presetName = info.name
			local groupName = info.group
			local maybeId = info.id
			local isLook = info.isLook
			local needsRename = info.needsRename
			local xmpString = info.xmpString
			local digest = info.digest
			
			local id = maybeId or KSIdGenerator.uuidInLowercaseHex()
			
			local obs = Observable.just( path )

			if needsRename then
				-- Until ACR provides an API that gives us control of the file name
				-- on preset save.
				obs = renameFileWithOzId( wfCatalog, styleSynchronizer, path, id )
				needsRefresh = true
			end
			
			return obs:map( function( newPath )
				
				return createPresetDocument( wfCatalog, presetName, groupName, newPath, isLook, id, xmpString, digest )
					
			end )
			
        end )
		
		:bufferWithCount( PRESET_CREATE_BATCH_SIZE )
		
		:flatMap( function( presetDocuments )
			
			return wfCatalog:docStore():bulkSetDocValue( presetDocuments )
				:tapOnError( function( err )
					logger:errorf( "Adding to catalog failed: err:%s", err )
				end )
		end )
		
        :toArray()
		
		:tap( function()
			
			if needsRefresh then
				-- Refresh the list if we are doing a style rename
				styleSynchronizer:triggerStyleRefresh()
			end
			
		end )
end

--------------------------------------------------------------------------------
--[[!
	\brief Renames a preset document
	
	\param wfCatalog (WFCatalog) catalog to receive the images
	\param params 
	
	\return Observable sends onCompleted when done
]]--

function NmbCatPreset.renamePreset( wfCatalog, params, styleSynchronizer )

    local xmpData = KSData.memoryMappedContentsOfFile( params.filePath )
	local xmpString = xmpData and tostring( xmpData ) or ""	
	
	if xmpData then
		xmpData:close()
		xmpData = nil
	end
	
	local sha256 = WFCrypto.SHA256.digest( xmpString )
	
	local overlay = {
		_id = params.id,
		name = params.newName,
		
		importSource = {
			fileSize = WFFileUtils.fileAttributes( params.filePath ).fileSize,
			sha256 = sha256,
		},
		
		_localOnly = {
			binaryUpload = {
				waitForInvalid = sha256 
			}
		},
	}

	-- add updated digest to the overlay if available
	if params.newDigest then
		overlay.digest = params.newDigest
	end
	
	overlay = WFCatUtils.addUserUpdatedFields( overlay )
	
	local path = params.filePath
	local obs = Observable.just( path )

	return obs:flatMap( function( newPath )
		overlay = formDocInfo( wfCatalog, newPath, overlay, true )
		return wfCatalog:docStore():applyOverlay( overlay )
	end )
end

--------------------------------------------------------------------------------
--[[!
	\brief Deletes a profile document
	
	\param wfCatalog (WFCatalog) catalog to receive the images
	\param id (GUID) Asset id of the profile document
	\param fingerprint (GUID) Favorite fingerprint of the profile
	
	\return Observable sends onCompleted when done
]]--

function NmbCatPreset.deleteProfile( wfCatalog, id, fingerprint )

    return Observable.combineLatest(
		NmbStyleFavorites.removeFavoriteInfo( wfCatalog, fingerprint, true ),
		wfCatalog:docStore():deleteDoc( id )
	)
end

--------------------------------------------------------------------------------
--[[!
	\brief Deletes a preset document
	
	\param wfCatalog (WFCatalog) catalog to receive the images
	\param presetIds (Table) List of preset docids to be deleted
	
	\return Observable sends onCompleted when done
]]--

function NmbCatPreset.deletePresets( wfCatalog, presetIds )

	return WFCatDelete.deleteDocs( wfCatalog:docStore(), presetIds )
end

--------------------------------------------------------------------------------
--[[!
	\brief Updates settings for a preset
	
	\param wfCatalog (WFCatalog) catalog to receive the images
	\param id (String) Id of the preset
	\param filePath (String) Absolute path of the preset file
	
	\return Observable sends onCompleted when done
]]--

function NmbCatPreset.updatePresetSettings( wfCatalog, id, filePath, digest )

	local newXmpData = KSData.memoryMappedContentsOfFile( filePath )
	local xmpString = tostring( newXmpData )
	if newXmpData then
		newXmpData:close()
		newXmpData = nil
	end
	
	local volumeGuid = filePath and WFFileUtils.getVolumeGuidForPath( filePath )
	wfCatalog:getDeletionBacklog():removeFromDeletionBacklog( volumeGuid, filePath )
		-- If the file has been previously added to the deletion backlog, clear it out
		-- so we don't delete it.
		
	local sha256 = WFCrypto.SHA256.digest( xmpString )
	
	local overlay = {
	
		_id = id,
		
		importSource = {
			fileSize = WFFileUtils.fileAttributes( filePath ).fileSize,
			sha256 = sha256,
		},
		
		_localOnly = {
			files = {
				original = {
					master = {
						sha256 = sha256,
					}
				}
			},

			binaryUpload = {
				waitForInvalid = sha256 
			}
		},

	}

	-- add updated digest to the overlay if available
	if digest then
		overlay.digest = digest
	end
	
	overlay = WFCatUtils.addUserUpdatedFields( overlay )
	
	return wfCatalog:docStore():applyOverlay( overlay )
end

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

local xmp
local attemptCountPerUUID = {}
local pathsToSkip = {}

local function slurpPaths( wfCatalog, styleSynchronizer, paths )

	local presetArray = {}
	
	-- Reading the preset/profile from disk is required in the case where we actually
	-- end up needing to sync it, for sha256 calculation.
	-- If we restrict the reading to items in the 'managed' locations, then we
	-- shouldn't end up reading things we don't need to sync, given that this
	-- is a one time task...
	
	xmp = xmp or WFXmp.WFXmp()
	
	for path in pairs( paths or {} ) do
	
		local readSuccess, fileContent = pcall( KSData.memoryMappedContentsOfFile, path )
		
		if readSuccess then
			
			local xmpString = tostring( fileContent )
			fileContent:close()
			
			local success = xmp:parse( xmpString )

			if success then
				local styleName = xmp:getStringList( WFXMLNS.crs, "Name" )
				styleName = styleName and styleName[ 1 ] or nil
				local presetType = xmp:get( WFXMLNS.crs, "PresetType" )
				local uuid = xmp:get( WFXMLNS.crs, "UUID" )
				local isLook = presetType == "Look"
				local groupName = xmp:getStringList( WFXMLNS.crs, "Group" )
				groupName = groupName and groupName[ 1 ] or nil
				
				local key = uuid or path
				if key then
					attemptCountPerUUID[ key ] = attemptCountPerUUID[ key ] or 0
					attemptCountPerUUID[ key ] = attemptCountPerUUID[ key ] + 1
					
					if attemptCountPerUUID[ key ] > 1 then
						pathsToSkip[ path ] = true -- flag for skipping in next iteration
					end
				end
				
				local renameWithOzId = attemptCountPerUUID[ key ] == 1 or false
				
				-- Crude check that this is actually a valid style document.
				if styleName and presetType then
					table.insert( presetArray, {
						name = styleName,
						isLook = isLook,
						filePath = path,
						group = groupName,
						uuid = uuid,
						needsRename = renameWithOzId,
						xmpString = xmpString,
					} )
				end
				
			end
			
		else
			-- Maybe the preset was deleted between being identified as a slurp
			-- candidate and now. (In the final implementation, file names
			-- will not be changing as a result of renames.) Move along....
		end
	end

	return NmbCatPreset.addPresetArrayToCatalog( wfCatalog, presetArray, styleSynchronizer )

end

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

local function catalogPath( wfCatalog )
	
	return wfCatalog:fileSpec():catalogPath()
	
end

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

local function findSlurpCandidatePaths( wfCatalog )

	-- Considering only contents of cr_settings directory, for now.
	-- DCP slurping will need to happen from locations outside of our managed
	-- catalog and include copying into the managed catalog location. No DCPs
	-- will already exist in the managed location the first time a preset/profile
	-- syncing Lr CC build runs on a system, so the slurping mechanism will
	-- be different for DCPs.

	local styleDir = KSPathUtils.child( catalogPath( wfCatalog ), "cr_settings" )
	WFFileUtils.mkdir( styleDir )
	
	local paths = {}
	
	for file in WFFolderWalker( styleDir ) do
		if file:lower():sub( #file - 3, #file ) == ".xmp" then
			paths[ file ] = true
		end
	end
	
	return paths

end

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

local lastDocCount
local docCountIdenticalNTimes = 0

function NmbCatPreset.maybeDoProfilePresetSlurp( wfCatalog, styleSynchronizer, optDelay )

	return Observable.timer( optDelay or WFConfiguration.styleSync.slurpDelay or 60,
										require 'WFRxScheduler'.sharedScheduler() )

	:flatMapLatest( function()

		local docStore = wfCatalog:docStore()

		return docStore:localDocValue( 'profilePresetSlurpDone' )

		:flatMap( function( docValue )
		
			if not WFConfiguration.styleSync.slurpOnEveryLaunch and
						docValue and docValue.profilePresetSlurpDone == true then
			
				return Observable.empty()
			
			else
			
				styleSynchronizer:getPresetProfileIngestInProgressStream():onNext( true )
		
				local indexStore = wfCatalog:indexStore()
				
				local pathsToSlurp = {}
				
				-- extract paths from docs, then determine which paths exist on disk that are
				-- NOT synced.
				
				return indexStore:queryOnce {
					sql = [[ SELECT docId from presetsAndProfiles ]],
					quiesceUntilUpToDate = true,
					singleColumnQuery = true,
				}

				:flatMap( function( idArray )
				
					if lastDocCount == #idArray then
						docCountIdenticalNTimes = docCountIdenticalNTimes + 1
						
						if docCountIdenticalNTimes >= 5 then
							-- For some reason, one or more files fail to get
							-- converted into docs in the docstore and we may
							-- never end the attempts. Escape hatch.
							
							logger:warnf( "Quitting preset/profile slurp due to lack of movement in profile/preset doc count." )
							
							return wfCatalog:docStore():setLocalDocValue {
								_id = 'profilePresetSlurpDone',
								profilePresetSlurpDone = true,
							}							
						end
					end
					
					lastDocCount = #idArray
					
					return docStore:bulkDocValue( idArray )

					:flatMap( function( docValueArray )
					
						local syncedPaths = {}

						for _, docVal in ipairs( docValueArray or {} ) do
						
							local fullPath = WFCatFileSpec.buildOriginalPathFromDocValue( docVal )
										
							if fullPath then
								syncedPaths[ fullPath ] = true
							end
						end

						local slurpCandidatePaths = findSlurpCandidatePaths( wfCatalog )
						
						-- Until we start using doc id as the file name for presets/profiles
						-- as a matter of course (not yet the case, due to ACR dependency),
						-- a user could conceivably rename a preset and cause the file path
						-- to change with a timing that causes the following check to 'fail',
						-- leading to an attempt to sync an already synced style document.
						-- That is a scenario that we also have to deal with from the angle
						-- of preset slurping potentially happening from more than one machine
						-- for the same account. So we don't need to take special precautions
						-- right here. (If a rename happened to a not-yet-synced file, it would
						-- be picked up in the next iteration.)
						
						-- (I've performed a couple of tests where I renamed the file on disk
						-- or via client UI while stopped at a breakpoint at the beginning
						-- of the for loop below. As expected, this results in failure to
						-- sync the preset in the first iteration of this function, but success
						-- is achieved in a later iteration (via recursion below).)
				
						local numSlurpPaths = 0
		
						for candidatePath in pairs( slurpCandidatePaths or {} ) do
						
							if not syncedPaths[ candidatePath ] then
								if not pathsToSkip[ candidatePath ] then
									pathsToSlurp[ candidatePath ] = true
									numSlurpPaths = numSlurpPaths + 1
									if numSlurpPaths == 100 then -- 100 (arbitrarily) at a time
										break
									end
								else
									logger:warnf( "Giving up on syncing %s after two attempts", candidatePath )
								end
							end
						end
						
						if numSlurpPaths == 0 then
							return wfCatalog:docStore():setLocalDocValue {
								_id = 'profilePresetSlurpDone',
								profilePresetSlurpDone = true,
							}
						end
						
						return slurpPaths( wfCatalog, styleSynchronizer, pathsToSlurp )
							:flatMap( function()
								-- Rinse and repeat until done.
								return NmbCatPreset.maybeDoProfilePresetSlurp( wfCatalog, styleSynchronizer, 10 )
							end )
						
					end )

				end )
				
			end
		
		end )
		
		:finally( function()
		
			styleSynchronizer:getPresetProfileIngestInProgressStream():onNext( false )
		
		end )
		
	end )
	
	:call( WFRxRunUntilDone )
	
end

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

return NmbCatPreset
