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

WFCatDCPs.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 KSIdGenerator = require 'KSIdGenerator'
local KSPathUtils = require 'KSPathUtils'
local KSTableUtils = require 'KSTableUtils'
local WFRxRunUntilDone = require 'WFRxRunUntilDone'
local WFFileUtils = require 'WFFileUtils'
local WFTableMerge = require 'WFTableMerge'
local WFAsyncFileUtils = require 'WFAsyncFileUtils'
local WFConfiguration = require 'WFConfiguration'
local WFAcrOperations = require 'WFAcrOperations'
local WFFolderWalker = require 'WFFolderWalker'
local WFLogger = require 'WFLogger'
local WFCrypto = require 'WFCrypto'
local WFCatUtils = require 'WFCatUtils'

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

local DCP_CONTENT_TYPE = "application/octet-stream"

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

local logger = WFLogger( "WFCatDCPs" )

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

local kAlreadyImported = 'already_imported'
local kInvalidCameraProfile = 'invalid_camera_profile'

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

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

end 

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

local function createDCPDocument( fingerprint, cameraProfileDigest, cameraModel, fileName, filePath, sha256, name )
	
	local fileSize = WFFileUtils.fileAttributes( filePath ).fileSize
	local subtype = 'camera_profile'
	local assetId = KSIdGenerator.uuidInLowercaseHex()

	local doc = {

		_id = assetId,
		
		type = 'asset',
		subtype = subtype,
		name = name,
		
		-- As per the data model spec, `type` is used to
		-- specify if the profile is a user profile or adobe
		-- profile. But the keyword type is already used to
		-- specify the document type. Though from data-model
		-- perspective, these two are in different levels
		-- ( one is in top level and the other is in payload ),
		-- the docstore apis dont support these two at the same
		-- time. Omitting this param for now.
		--type = 'user',
		version = 1,
		resolvedDigest = fingerprint,
		fileSize = fileSize,
		fileName = fileName,
		
		-- These two fields are not currently (4/4/2018) in the Oz data model, but
		-- there also doesn't appear to be any validation preventing us from using them
		-- anyway. Data model pull requests are pending to get the fields included.
		model = cameraModel,
		cameraProfileDigest = cameraProfileDigest:lower(),
		
		_localOnly = {
			files = {
				original = {
					master = {
						relativePath = WFFileUtils.removeVolumeFromPath( filePath ),
						volumeGuid = WFFileUtils.getVolumeGuidForPath( filePath ),
						sha256 = sha256,
						size = fileSize,
					}
				}
			},
			cameraProfileDigest = cameraProfileDigest:lower(),
			cameraProfileUniqueID = fingerprint:lower(),
			cameraModel = cameraModel,
			profileLinkChecksPending = 1,
				-- This field is needed because only the client from which a
				-- custom DCP got uploaded should attempt to request new
				-- renditions for images referencing that DCP from before
				-- the DCP became available for rendition generation on Oz,
				-- or fix up the profile links section for such assets.
				-- Any other 'policy' could result in renditions getting
				-- requested once for each different Lr CC client (as opposed
				-- to just once, which is what we want). Also, per Matt J
				-- Lr Classic has given us some bad experiences in cases where
				-- any instance made attempts to fix up documents.
				--
				-- Granted this may lead to weirdness if a user happens to
				-- fire up an infrequently used instance of Lr CC as the first
				-- Lr CC 1.4(+) instance, then shuts it down again with work
				-- remaining with regards to rendtion requestiong an link
				-- fixing, but that is something we will likely just need
				-- to accept.
		},
		
		importSource = {
			fileSize = fileSize,
			sha256 = sha256,
			contentType = DCP_CONTENT_TYPE,
		},
	}
	
	doc = formDocInfo( filePath, doc )
	
	WFCatUtils.addUserCreatedFields( doc )

	return doc

end

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

local function createDCPDocuments( dcpInfoWithSha256 )

	return Observable.from( dcpInfoWithSha256 )
	
	:map( function( dcpItem )
	
		return createDCPDocument( dcpItem.fingerprint,
			dcpItem.cameraProfileDigest, dcpItem.cameraModel,
			dcpItem.fileName, dcpItem.filePath, dcpItem.sha256, dcpItem.name )
	
	end )
	
	:toArray()

end

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

local function addDCPsToCatalog( wfCatalog, dcpsToAdd )
	
	return createDCPDocuments( dcpsToAdd )
	
	:flatMap( function( documents )
	
		return wfCatalog:docStore():bulkSetDocValue( documents )
		
		:map( function( results )
		
			for i, item in ipairs( results ) do
			
				item.filePath = dcpsToAdd[ i ] and dcpsToAdd[ i ].filePath
				
				return results
			
			end
		
		end )
		
	end )
	
	
end

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

local WFCatDCPs = {}

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

function WFCatDCPs.syncDCPs( wfCatalog, dcpInfoArray )

	if dcpInfoArray then
	
		local needSync = {}
	
		local indexStore = wfCatalog:indexStore()
	
		local alreadySyncedQuery = indexStore:queryOnce {
			sql = [[ SELECT cameraProfileUniqueID FROM customCameraProfiles ]],
			singleColumnQuery = true
		}
	
		return alreadySyncedQuery
		:flatMap( function( alreadySyncedFingerPrints )
		
			alreadySyncedFingerPrints = KSTableUtils.mapArrayToSet( alreadySyncedFingerPrints )
			local alreadySyncedRejects = {}
			
			for _, dcpItem in ipairs( dcpInfoArray ) do
			
				if not alreadySyncedFingerPrints[ dcpItem.fingerprint ] then
					table.insert( needSync, {
						fingerprint = dcpItem.fingerprint,
						cameraProfileDigest = dcpItem.cameraProfileDigest,
						cameraModel = dcpItem.cameraModel,
						filePath = dcpItem.filePath,
						fileName = KSPathUtils.leafName( dcpItem.filePath ),
						sha256 = dcpItem.sha256,
						name = dcpItem.name,
					} )
				else
					table.insert( alreadySyncedRejects, {
						filePath = dcpItem.filePath,
						msg = kAlreadyImported,
					} )
				end
			end
		
			-- At this point, what remains in needSync needs to be synced.
			
			if not next( needSync ) then
			
				return Observable.just( alreadySyncedRejects )
			
			end
			
			return addDCPsToCatalog( wfCatalog, needSync )
			
			:map( function( addResult )
			
				for _, item in ipairs( alreadySyncedRejects ) do
				
					table.insert( addResult, item )
				
				end
				
				return addResult
			
			end )
		
		end )
	end
	
	return Observable.just( nil )

end

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

-- TODO: Consider the following:

-- Always calculate sha256 for source path, so it can be passed back out
-- to eliminate the need for 'asyncCopyAndAddSha256DigestsToDcpInfoArray'
-- to immediately calculate it again. This will only be a performance win
-- in the case where there is already a file at the source destination path.

local function copyIfNotExists( sourcePath, dstPath )

	-- We are copying custom DCPs into our managed 'cr_cameraprofiles' location.
	-- We don't want to allow more than one revision of any DCP, because
	-- we want the original revision around for assets that reference it, in
	-- the same manner we do things for Adobe provided DCPs.
	-- Therefore, if a DCP with the same name but a different sha256 needs
	-- to be copied into our managed location, we do need to give it a unique
	-- name. (This could happen as a result of the DCP's creator not adhering
	-- to the philosophy of not revisioning DCPs, probably.)

	if WFFileUtils.exists( dstPath ) == "file" then
	
		-- Check that this is indeed the same file; probably need to go lighter
		-- weight here? File size?
		
		local srcSha256
		
		return WFCrypto.asyncDigestFile( sourcePath, 'SHA256' )
		
		:flatMap( function( sourceSha256 )
		
			srcSha256 = sourceSha256
			
			return WFCrypto.asyncDigestFile( dstPath, 'SHA256' )
		
		end )
		
		:flatMap( function( dstSha256 )
		
			if srcSha256 == dstSha256 then
			
				-- Return the path to the already existing file without copying.
				return Observable.just( dstPath )
			
			end
		
			return WFAsyncFileUtils.copyToUniquelyNamedFile( sourcePath, dstPath )

		end )
		
		:catch( function( errInf )
		
			logger:errorf( "Error copying %s to %s: %s", sourcePath, dstPath, errInf )
			
			return Rx.Observable.just( nil )
		
		end )
		
	end

	return WFAsyncFileUtils.copyToUniquelyNamedFile( sourcePath, dstPath )
	
end

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

local function asyncCopyAndAddSha256DigestsToDcpInfoArray( dcpInfoArray, dstFolder )

	-- Allow for forcing of failure, for testing purposes.
	local forceFailureString = WFConfiguration.syncDCP.forceSha256Failure
	if forceFailureString then
		forceFailureString = tostring( forceFailureString ):lower()
	end

	local numItems = #dcpInfoArray
	local sha256Map = {}
	
	return Rx.Observable.from( dcpInfoArray )
	
	:flatMap( function( dcpItem )

		if forceFailureString then			
			if dcpItem.filePath:lower():find( forceFailureString ) then
				dcpItem.filePath = dcpItem.filePath .. "2"
			end
		end

		local fileName = KSPathUtils.leafName( dcpItem.filePath )
		local dstPath = KSPathUtils.child( dstFolder, fileName )

		return copyIfNotExists( dcpItem.filePath, dstPath )
		
		:flatMap( function( resultPath )
		
			dcpItem.filePath = resultPath
	
			return WFCrypto.asyncDigestFile( dcpItem.filePath, 'SHA256' )
			
			:map( function( sha256 )
			
				sha256Map[ dcpItem.filePath ] = sha256
				
				dcpItem.sha256 = sha256
				
				return sha256
			
			end )
			
		end )
		
		:catch( function( errInf )
		
			sha256Map[ dcpItem.filePath ] = "failure"
			return Rx.Observable.just( "failure" )
		
		end )
	
	end )
	
	:take( numItems )
	
	:toArray()
	
	:map( function( array )
	
		local invalidIdx = {}
	
		for i, item in ipairs( dcpInfoArray ) do
		
			if sha256Map[ item.filePath ] == "failure" then
				table.insert( invalidIdx, i )
			end
		
			item.sha256 = sha256Map[ item.filePath ]
		
		end
		
		-- Remove items for which a sha256 could not be calculated, if any.
		-- Remove items in reverse order, so as to keep indices valid.
		
		table.sort( invalidIdx, function( e1, e2 )
			if e1 > e2 then
				return true
			end
			
			return false
		end )
		
		for i, idx in ipairs( invalidIdx ) do
			logger:warnf( "removing %s from DCPs to sync, due to failure to compute sha256",
							dcpInfoArray[ idx ].filePath )
			table.remove( dcpInfoArray, idx )
		end
		
		return dcpInfoArray
	
	end )

end

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

local function isValidCustomDCP( filePath, dirsToExclude )

	for _, dir in ipairs( dirsToExclude or {} ) do
		if KSPathUtils.isPathParentOf( dir, filePath ) then
			return Rx.Observable.just( false )
		end
	end
	
	return WFAcrOperations.getCustomCameraProfileInfo( filePath )
	:map( function( profileInfoOrError )
	
		if type( profileInfoOrError ) == 'table' then
			profileInfoOrError.filePath = filePath
			profileInfoOrError.cameraProfileDigest = profileInfoOrError.cameraProfileDigest:lower()
			profileInfoOrError.fingerprint = profileInfoOrError.resolvedDigest:lower()
			return true, profileInfoOrError
		end
		
		return false, profileInfoOrError
	end )
	
	:catch( function( errInf )

		-- Not much we can do about that, so carry on after logging.
		logger:errorf( "error occurred while attempting to get info for %s", filePath )

		return Rx.Observable.just( nil )
	end )
end

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

local function scanForCustomDCPInfo( appDataFolderInfo )

	local userAdobeFolder = appDataFolderInfo and appDataFolderInfo.userAdobeFolder
	local globalAdobeFolder = appDataFolderInfo and appDataFolderInfo.globalAdobeFolder
	local userCameraRaw = KSPathUtils.child( userAdobeFolder, "CameraRaw" )
	local globalCameraRaw = KSPathUtils.child( globalAdobeFolder, "CameraRaw" )
	
	local userCameraProfiles = KSPathUtils.child( userCameraRaw, "CameraProfiles" )
	local globalCameraProfiles = KSPathUtils.child( globalCameraRaw, "CameraProfiles" )

	local allDCPs = {}
	local sourceDirs = {
		userCameraProfiles,
		globalCameraProfiles,
	}
	
	local adobeTopDirs = {
		KSPathUtils.child( globalCameraProfiles, "Adobe Standard" ),
		KSPathUtils.child( globalCameraProfiles, "Camera" ),
		KSPathUtils.child( userCameraProfiles, "Adobe Standard" ),
		KSPathUtils.child( userCameraProfiles, "Camera" ),
	}
	
	for _, dir in ipairs( sourceDirs ) do
		for file in WFFolderWalker( dir ) do
			if WFFileUtils.exists( file ) == 'file' and file:lower():sub( #file - 3, #file ) == ".dcp" then
				table.insert( allDCPs, file )
			end		
		end
	end
	
	return Rx.Observable.from( allDCPs )
	
	:flatMap( function( dcpFilePath )
	
		return isValidCustomDCP( dcpFilePath, adobeTopDirs )
		
		:map( function( valid, profileInfo )
		
			if valid then
				return profileInfo
			end
		
		end )
	
	end )
	
end

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

local function getCustomDCPInfoForPaths( filePaths )

	local allDCPs = {}

	for _, filePath in ipairs( filePaths ) do
		if WFFileUtils.exists( filePath ) == 'file' and filePath:lower():sub( #filePath - 3, #filePath ) == ".dcp" then
			table.insert( allDCPs, filePath )
		end		
	end

	return Rx.Observable.from( allDCPs )
	
	:flatMap( function( dcpFilePath )
	
		return isValidCustomDCP( dcpFilePath )
		
		:map( function( valid, profileInfo )
		
			if valid then
				return profileInfo
			else
				return {
					err = true,
					filePath = dcpFilePath,
					msg = kInvalidCameraProfile
				}
			end
		
		end )
	
	end )


end

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

function WFCatDCPs.copyAndSyncDCPs( wfCatalog, appDataFolderInfo )

	local catalogPath = wfCatalog:fileSpec():catalogPath()
	local copyTo = KSPathUtils.child( catalogPath, "cr_cameraprofiles" )
	WFFileUtils.mkdir( copyTo )
	
	local docStore = wfCatalog:docStore()
	
	return docStore:localDocValue( 'dcpSlurpDone' )
	
	:flatMapLatest( function( docValue )
	
		if not WFConfiguration.dcpSync.slurpOnEveryLaunch and
						docValue and docValue.dcpSlurpDone == true then
			
				return Observable.empty()
		end
		
		return scanForCustomDCPInfo( appDataFolderInfo )
		
		:bufferWithCount( 100 )
		
		:flatMap( function( itemBatch )
		
			-- Copy (if necessary) and add sha256 digest for each item in 'itemBatch'.
			return asyncCopyAndAddSha256DigestsToDcpInfoArray( itemBatch, copyTo )
		
		end )
		
		:flatMap( function( dcpItemBatch )
		
			return WFCatDCPs.syncDCPs( wfCatalog, dcpItemBatch )
		
		end )
		
	end )
	
	:finally( function()
	
		-- If this does not end up succeeding in setting the flag, i.e.
		-- Lr CC was shutting down when we got here, it will succeed next
		-- time Lr CC gets this far, assuming it's not shutting down then
		-- too.

		wfCatalog:docStore():setLocalDocValue {
						_id = 'dcpSlurpDone',
						dcpSlurpDone = true,
					}
		:call( WFRxRunUntilDone )
	
	end )
	
	:catch( function( msgOrWhatNot )
	
		if type( msgOrWhatNot ) == 'string' then
			logger:errorf( "an error occurred during custom DCP slurping: %s", msgOrWhatNot )
		end
		
		-- For now, take the approach of just letting DCP slurping happen again
		-- on the next launch.
		
		return Observable.just( false )
	
	end )
	
	:call( WFRxRunUntilDone )
	
end

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

function WFCatDCPs.importDCPs( wfCatalog, explicitProfilePathsToImport )

	local copyTo = KSPathUtils.child( wfCatalog:fileSpec():catalogPath(), "cr_cameraprofiles" )
	local invalidItems = {}

	return getCustomDCPInfoForPaths( explicitProfilePathsToImport )

		:bufferWithCount( 100 )
		
		:flatMap( function( itemBatch )
		
			local validItems = {}
			
			for _, item in ipairs( itemBatch ) do
			
				if item.err then
					table.insert( invalidItems, item )
				else
					table.insert( validItems, item )
				end
			
			end
		
			-- Copy (if necessary) and add sha256 digest for each item in 'itemBatch'.
			return asyncCopyAndAddSha256DigestsToDcpInfoArray( validItems, copyTo )
		
		end )
		
		:flatMap( function( dcpItemBatch )
		
			return WFCatDCPs.syncDCPs( wfCatalog, dcpItemBatch )
			
			:map( function( rawResults )
			
				local refinedResults = {}
				
				for _, item in ipairs( rawResults ) do
					table.insert( refinedResults, {
						success = item.ok == true,
						msg = item.msg,
					} )
				end
				
				for _, item in ipairs( invalidItems ) do
					table.insert( refinedResults, {
						success = false,
						msg = item.msg,
					} )
				end
			
				return refinedResults
				
			end )
		
		end )
		
	:catch( function( msgOrWhatNot )
	
		if type( msgOrWhatNot ) == 'string' then
			logger:errorf( "an error occurred during custom DCP import: %s", msgOrWhatNot )
		end
		
		return Observable.just( false )
	
	end )


end

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

return WFCatDCPs
