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

WFCatUser.lua

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

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

NOTICE: All information contained herein is, and remain 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 may be covered by U.S. and Foreign
Patents, patents in process, and are protected by trade secret or copyright
law. Dissemination of this information or reproduction of this material is
strictly forbidden unless prior written permission is obtained from
Adobe Systems Incorporated.

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

	-- RxLua
local Rx = require 'Rx'
local Observable = Rx.Observable
local ReplaySubject = Rx.ReplaySubject
local BehaviorSubject = Rx.BehaviorSubject

	-- WF.core.logging
local WFLogger = require 'WFLogger'

	-- WF.core.os
local KSPathUtils = require 'KSPathUtils'
local WFFileUtils = require 'WFFileUtils'
local WFLocalizationUtils = require 'WFLocalizationUtils'

	-- WF.core.utils
local KSIdGenerator = require 'KSIdGenerator'
local KSTableUtils = require 'KSTableUtils'
local WFArguments = require 'WFArguments'
local _ = require 'WFArguments_uuid'
local WFCloseSupport = require 'WFCloseSupport'
local WFConfiguration = require 'WFConfiguration'
local WFObjectProxy = require 'WFObjectProxy'
local WFTableMerge = require 'WFTableMerge'
local WFRxScheduler = require 'WFRxScheduler'
local WFCapabilities = require "WFCapabilities"
local WFPlatformInfo = require 'WFPlatformInfo'

	-- WF.oz_client.ims
local WFImsAppIdentity = require 'WFImsAppIdentity'

	-- WF.oz_client.managed_catalog
local WFCatSpec = require 'WFCatSpec'

	-- WF.web.oauth2_client
local WFOAuth2Session = require 'WFOAuth2Session'

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

	-- WF.web.http_client
local WFRxHttpClient = require 'WFRxHttpClient'

	-- WF.web.utils
local WFURI = require 'WFURI'

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

local logger = WFLogger 'WFCatUser'
--	:setLevel 'debug'

local check = assert( WFArguments.check )
local derefOpt = assert( KSTableUtils.derefOpt )

local doInjectForceUpgradeErrorParameter =
			WFCapabilities.getCapability('WFCatUser.forceUpgrade') == 'true' or require "WFConfiguration".inAppMessaging.upgradeRequired or false

local kTooManyCatalogsError = 'TooManyCatalogsError'

local findOrCreateCatalogCountByOzUserId = {}

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

-- @mark WFCatUser class

--------------------------------------------------------------------------------
--[[!
	\class WFCatUser

	\brief A \c WFCatUser gathers together the very basic information about an
		Oz account so that other classes that do the work of communicating with
		that account can share a common vocabulary for accessing that user's
		information.

	Please help keep this class simple and STATELESS!
]]--

local WFCatUser = {

	_endpointIndex = false,
		-- (table) most recent table of base URLs for Oz server being used (e.g. ( e.g. { serverBase = "https://api.photos.com",
		-- binaryBase = "https://imagedown.photos.com } )

	_endpointIndexSubscription = false,
		-- (Rx Subscription) Fires with a table of base URL endpoints for Oz server ( e.g. { serverBase = "https://api.photos.com",
		-- binaryBase = "https://imagedown.photos.com } )

	_isEndpointIndexReady = false,
		-- (Rx.BehaviorSubject) Fires with true once the endpoint index has fired.

	_ozUserId = false,
		-- (string) Oz user ID (UUID)

	_path = false,

	_oAuth2Session = false,
		-- (WFOAuth2Session) OAuth2 session for talking to Oz via IMS
		-- created lazily

	_userInfoCache = false,
		-- (WFRxDocStore) this doc store retains info about this user.

	_imsClientId = false,
		-- (string) client app ID used with IMS

	_updateRequired = false,
		-- (Rx.Observable)

	WFCloseSupport.addCloseSupportMethods,

}

--------------------------------------------------------------------------------
--[[!
	\brief Constructs the user.

	\param serverBase (string) base URL for Oz server being used
		(e.g. "https://photos-stage.adobe.io")
	\param userId (string) UUID for the user on Oz
	\param path (string) local device path to store user information, use ':memory:' to specify
		an in-memory user info cache (for testing purposes )
]]--

function WFCatUser:__init( serverBase, ozUserId, path )

	check( 'WFCatUser', '+string', '+string', '+string' )

	self._endpointIndex = {
		serverBase = serverBase,
		binaryBase = false,
		maskingServerBase = false
	}

	if( not ozUserId:match( "^[0-9a-f]+$" ) ) or #ozUserId ~= 32 then
		error( "WFCatUser: user UUID malformed: " .. ozUserId, 4 )
	end

	self._path = path

	self._ozUserId = ozUserId

    self._imsClientId = WFImsAppIdentity.getClientId()

	logger:infof( "WFCatUser initializing with IMS client ID = %s", self._imsClientId )

	self._isEndpointIndexReady = BehaviorSubject( false )

	self._endpointIndexSubscription = Rx.Observable.combineLatest(

		function( staticEndpoints, endpointIndexDocument )

			local ozServerBase = derefOpt( endpointIndexDocument, 'base' )
			local ozBinaryBaseUrl = derefOpt( endpointIndexDocument, 'links', '/rels/storage', 'base' )
			local ozVersionUrl = derefOpt( endpointIndexDocument, 'links', '/rels/version', 'href' )
			local ozMaskingServerBaseUrl = derefOpt( endpointIndexDocument, 'links', '/rels/imaging', 'base' )

			local serverBase = ozServerBase or staticEndpoints.serverBase

			local binaryBase = ozBinaryBaseUrl or serverBase

			local maskingServerBase = ozMaskingServerBaseUrl

			return {
				serverBase = serverBase,
				binaryBase = binaryBase,
				versionUrl  = serverBase and ozVersionUrl and WFURI.resolve( serverBase, ozVersionUrl ) or nil,
				maskingServerBase = maskingServerBase
			}

		end, Rx.Observable.just( self._endpointIndex ), self:userInfoCache():ongoingLocalDocValue( 'Oz-EndpointIndexDocument' ) )

		:subscribe( function( endpointIndex )

			self._endpointIndex = endpointIndex
			self._isEndpointIndexReady:onNext( true )

		end )

	self._updateRequired = ReplaySubject( 1 )
	self._updateRequired:onNext( false )

	WFCloseSupport.mustClose( self )

end

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

function WFCatUser:close()

	self._endpointIndexSubscription:dispose()

end

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

function WFCatUser:__tostring()

	check( 'WFCatUser' )

	return string.format( "WFCatUser( id = %s, server = %s, path = %s )",
						  self._ozUserId,
						  self:serverBaseUrl(),
						  self._path )

end

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

function WFCatUser:getRequiredUpdateStream()

	check( 'WFCatUser' )

	return self._updateRequired
end

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

local function isEndpointIndexReady( self )

	return self._isEndpointIndexReady
		:filter( function( isReady )
			return isReady
		end )
		:take( 1 )

end

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

-- @mark basic info about user

--------------------------------------------------------------------------------
--[[!
	\brief Retrieves the unique ID of this Oz user.

	\return (string) The ID.
]]--

function WFCatUser:userId()

	check( 'WFCatUser' )

	return self._ozUserId

end

--------------------------------------------------------------------------------
--[[!
	\brief Returns the base URL for the server.

	\return (string) URL for the catalog
]]--

function WFCatUser:serverBaseUrl()

	check( 'WFCatUser' )

	return self._endpointIndex.serverBase

end

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

function WFCatUser:serverVersionUrl()

	check( 'WFCatUser' )

	return self._endpointIndex.versionUrl

end

--------------------------------------------------------------------------------
--[[!
	\brief Returns the base URL for the masking server.

	\return (string) URL for the masking server.
]]--

function WFCatUser:ozMaskingServerBaseUrl()

	check( 'WFCatUser' )

	return self._isEndpointIndexReady

		:filter( function()
			return not not self._endpointIndex.maskingServerBase
		end )

		:take( 1 )

		:map( function()
			return self._endpointIndex.maskingServerBase
		end )

end

--------------------------------------------------------------------------------
--[[!
	\brief Get the folder where the user info doc store can be found.

	\return (string) path
]]--

function WFCatUser:userInfoCacheFolder()

	check( 'WFCatUser' )

	return self._path

end

--------------------------------------------------------------------------------
--[[!
	\brief Get the path to the user info doc store.

	\return (string) path
]]--

function WFCatUser:userInfoCachePath()

	check( 'WFCatUser' )

	local userId = self._ozUserId

	if self._path == ':memory:' then
		return self._path .. self._ozUserId
	end

	WFFileUtils.mkdir( self._path )
	local userInfoCachePath = KSPathUtils.child( self._path,
										   'oz-user-' .. userId )

	return userInfoCachePath

end

--------------------------------------------------------------------------------
--[[!
	\brief Construct a doc store and index store to cache information about
		this user.

	\return doc store, index store
]]--

function WFCatUser:userInfoCache()

	check( 'WFCatUser' )

	if not self._userInfoCache
		or WFCloseSupport.hasBeenOrWillBeClosed( self._userInfoCache )
	then

		local userInfoCachePath = self:userInfoCachePath()

		self:setOwned {
			_userInfoCache = require 'WFRxDocStore'( userInfoCachePath )
		}

	end

	return self._userInfoCache

end

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

function WFCatUser:userIdIMS()

	check( 'WFCatUser' )

	return self:userInfoCache():docValue( self._ozUserId )
		:map( function( docValue )

			local userIdIMS = derefOpt( docValue, 'wcd_guid' )

			if userIdIMS then

				if not string.find( userIdIMS, '@' ) then

					userIdIMS = userIdIMS .. "@AdobeID"

				end

				return userIdIMS

			else

				logger:errorf( "Faild to get userIdIMS for userId %s", self._ozUserId )

				return ""

			end

		end )

end

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

function WFCatUser:entitlement()

	check( 'WFCatUser' )

	return self:userInfoCache():ongoingFieldValue( self._ozUserId, 'entitlement' )

end

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

function WFCatUser:isSubscriptionExpired()

	check( 'WFCatUser' )

	return self:userInfoCache():ongoingFieldValue( self._ozUserId, 'entitlement/status' )
		:map( function( status )
			return status == 'trial_expired'
				or status == 'subscription_expired'
		end )

end

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

local function oAuth2SessionForSelf( self )

	local result = self._oAuth2Session

	if not result then
		logger:debugf("create OAuthSession with userID = %s", self._ozUserId)
		result = WFOAuth2Session( self._ozUserId, self:serverBaseUrl() )
		self:setOwned { _oAuth2Session = result }
	end

	return result

end

--------------------------------------------------------------------------------
--[[!
	\brief Assign new tokens to this OAuth2 session.

	Typically only done once at login time, though this is not enforced.

	\param accessToken (string)
	\param refreshToken (string)
	\param optDeviceToken (string)

	\return Observable< true > resolves when compelted
]]--

function WFCatUser:storeTokens( accessToken, optRefreshToken, optDeviceToken )

	check( 'WFCatUser', '+string', '?+string', '?+string' )

	logger:debugf("call store tokens on oauth session")

	return oAuth2SessionForSelf( self ):storeTokens( accessToken, optRefreshToken, optDeviceToken )

end

--------------------------------------------------------------------------------
--[[!
	\brief Fetches the tokens for this OAuth2 session.

	\return Observable< accessToken (string), refreshToken (string), optDeviceToken (string) >
]]--

function WFCatUser:fetchTokens()

	check( 'WFCatUser' )

	return oAuth2SessionForSelf( self ):fetchTokens()

end

--------------------------------------------------------------------------------
--[[!
	\brief Simulates logout from the current authorization session.
]]--

function WFCatUser:simulateForcedLogout()

	check( 'WFCatUser' )

	return oAuth2SessionForSelf( self ):simulateForcedLogout()

end

--------------------------------------------------------------------------------
--[[!
	\brief Reports (as a behavior) whether this user has a current valid
		connection to the Oz server.

	\return <code>ReplayObservable< boolean ></code> An Observable whose
		value is \c true while there is a valid connection, and becomes
		\c false if the OAuth2 refresh token for this user is ever rejected.
]]--

function WFCatUser:hasValidConnectionToOz()

	check( 'WFCatUser' )

	return oAuth2SessionForSelf( self ):hasValidLogin()

end

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

-- @mark HTTP requests

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

local function mapPartialUrlToCompleteUrl( self, baseUrl, url )

	assert( baseUrl )
	assert( url )

	if url:match( "^/" ) then
		return WFURI.resolve( baseUrl, url )
	else
		return url
	end

end

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

local function addClientIdToArgs( self, optArgs )

	optArgs = optArgs or {}
	optArgs.requestHeaders = optArgs.requestHeaders or {}

	local clientIdAlreadyExists = false
	for _, header in ipairs( optArgs.requestHeaders ) do
		if header.header and header.header == 'X-Api-Key' then
			clientIdAlreadyExists = true
			break
		end
	end

	if not clientIdAlreadyExists then
		optArgs.requestHeaders[ #optArgs.requestHeaders + 1 ] = {
			header = 'X-Api-Key',
			value = optArgs[ "X-Api-Key" ] or self._imsClientId,
		}
	end

	return optArgs

end

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

local function sanitizeOverlay( overlay )
 	local underscoreByte = string.byte( '_' )
	-- remove top level keys that start with _ from the overlay
	for k, _ in pairs( overlay ) do
		if k:byte( 1 ) == underscoreByte then
			overlay[ k ] = nil
		end
	end

	-- also don't allow docValue.type and subtype in the overlay as these aren't supposed
	-- to be changed so any changes from Oz should be ignored
	overlay.type = nil
	overlay.subtype = nil

	return overlay
end

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

local function gotForcedUpgradeResponse( response, headers )

	local status = headers and headers.status_code and
								tonumber( headers.status_code )

	if status and status == 403 and WFRxHttpClient.responseContentTypeIsJson( headers ) then

		local decodedResponse = KSJsonDecoder.decodeWebSafeJson( response )
		-- Check for 4999 code in the response.

		if decodedResponse and decodedResponse.code and
				tonumber( decodedResponse.code ) == 4999 then
			return true
		end
	end

	return false

end

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

local function maybeRequiredUpdate( wfCatUser, response, headers )
	if gotForcedUpgradeResponse( response, headers ) then
		wfCatUser:getRequiredUpdateStream():onNext( true )
	end
end

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

--[[!
	\brief Starts an HTTP GET request, signing the request with the current
		access token of the current user and handles refreshing the access
		token if needed. Returns the response via an Observable.

	\param url (string) The URL to fetch. If it is a server-relative URL
		(e.g. "/v2/..."), the URL will be assumed to be relative to the core
		server URL for this user.
	\param optArgs (optional, table) Options to modify the request.

	\return <code>Observable< data, headers ></code> An Observable that resolves to the response
		from the HTTP server


	\sa WFRxHttpClient.get
]]--

function WFCatUser:get( url, optArgs )

	check( 'WFCatUser', 'string', '?table' )

	if doInjectForceUpgradeErrorParameter then
		if not url:find( "%?" ) then
			-- no params in the orignal url
			url = string.format( "%s%s", url, "?force_upgrade_error" )
		else
			-- original url already has params; inject force_error_upgrade=1 as the first one
			url = url:replace( "?", "?force_upgrade_error=1&" )
		end
	end

	return isEndpointIndexReady( self )

		:flatMap( function()

			return oAuth2SessionForSelf( self )
				:get( mapPartialUrlToCompleteUrl( self, self._endpointIndex.serverBase, url ),
											 addClientIdToArgs( self, optArgs ) )

				:tapOnError( function( response, headers )
					maybeRequiredUpdate( self, response, headers )
				end )
		end )

end

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

--[[!
	\brief Starts an HTTP GET request for a Binary, signing the request with the current
		access token of the current user and handles refreshing the access
		token if needed. Returns the response via an Observable. This API uses a
		different Oz endpoint that is optimized for binary transfer via a
		Content-Delivery Network


	\param url (string) The URL to fetch. If it is a server-relative URL
		(e.g. "/v2/..."), the URL will be assumed to be relative to the core
		server URL for this user.
	\param optArgs (optional, table) Options to modify the request.

	\return <code>P< data, headers ></code> An Observable that resolves to the response
		from the HTTP server

	\sa WFHttpClient.get
]]--

function WFCatUser:getBinary( url, optArgs )

	check( 'WFCatUser', 'string', '?table' )

	return isEndpointIndexReady( self )

		:flatMap( function()

			return oAuth2SessionForSelf( self )
				:get( mapPartialUrlToCompleteUrl( self, self._endpointIndex.binaryBase, url ),
											 addClientIdToArgs( self, optArgs ) )

		end )

end

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

--[[!
	\brief Starts an HTTP PUT request, signing the request with the current
		access token of the current user and handles refreshing the access
		token if needed. Returns the response via an Observable.

	\param url (string) The URL to fetch. If it is a server-relative URL
		(e.g. "/v2/..."), the URL will be assumed to be relative to the core
		server URL for this user.
	\param data (\c KSData or table) data to send with the request
		(use a Lua table for form data)
	\param optArgs (optional, table) options to modify the request

	\return <code>Observable< data, headers ></code> An Observable that resolves
		to the response from the HTTP server

	\sa WFRxHttpClient.put
]]--

function WFCatUser:put( url, data, optArgs )

	check( 'WFCatUser', '+string', '_', '?table' )

	return isEndpointIndexReady( self )

		:flatMap( function()

			return oAuth2SessionForSelf( self )
				:put( mapPartialUrlToCompleteUrl( self, self._endpointIndex.serverBase, url ),
											 data,
											 addClientIdToArgs( self, optArgs ) )

				:tapOnError( function( response, headers )
					maybeRequiredUpdate( self, response, headers )
				end )
		end )

end

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

--[[!
	\brief Starts an HTTP PUT request, signing the request with the current
		access token of the current user and handles refreshing the access
		token if needed. Returns the response via an Observable. This API uses a
		different Oz endpoint that is optimized for binary transfer via a
		Content-Delivery Network

	\param url (string) The URL to fetch. If it is a server-relative URL
		(e.g. "/v2/..."), the URL will be assumed to be relative to the core
		server URL for this user.
	\param data (\c KSData or table) data to send with the request
		(use a Lua table for form data)
	\param optArgs (optional, table) options to modify the request

	\return <code>Observable< data, headers ></code> An Observable that resolves
		to the response from the HTTP server

	\sa WFHttpClient.put
]]--

function WFCatUser:putBinary( url, data, optArgs )

	check( 'WFCatUser', '+string', '_', '?table' )

	return isEndpointIndexReady( self )

		:flatMap( function()

			return oAuth2SessionForSelf( self )
				:put( mapPartialUrlToCompleteUrl( self, self._endpointIndex.binaryBase, url ),
											 data,
											 addClientIdToArgs( self, optArgs ) )

		end )

end

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

--[[!
	\brief Logs this user out of the current Oz session.
]]--

function WFCatUser:logout()

	check( 'WFCatUser' )

	logger:infof( "attempting to log out user %s", self._ozUserId )

	-- Delete the cached user information for this user.

	-- TODO [arahn 2014-05-14]: We need a way to do this safely that doesn't impede
	-- our ability to relogin in the same app session, or later.
	-- Things we've tried that don't work:

	--	1) Deleting the user from the doc store. This leaves behind a tombstone
	--	which causes errors upon relogin because the user is going from deleted->
	--	non deleted, which the doc store does not like.

	--	2) Calling haltAndDelete on the doc and index stores for the user. This
	--	is problematic because OzUser is cached and shared via WFObjectProxy. Enough
	--	places in the code "leak" user documents, that upon logout, they aren't all
	--	closed. Thus upon re-login, the same oz user is recycled, and its member
	--	user info cache objects are dead. (A new oz user object would not have this problem
	--	because new doc store and index store would be created for it.)

	--	3) Simply closing and nil-ing out the info caches.

	--	4) Maybe we can try 2 again, but update the object proxy cache mechanism to
	--	not recycle a user whose info cache is dead... No that doesn't work because
	--	there are other places in the code we create WFCatUser objects. They share the same
	--	docstore and indexstore message processors. Those MP are effectively dead
	--	if we call haltAndDeleteP -- the only way to "restart" them is if all references
	--	to them are closed, at which point the MPs are shut down. Then creating a new
	--	user would be okay because upon the messaging processor getting initialized it
	--	would set up new DBs for the docstore/indexstore. But finding and closing
	--	all WFCatUser objects would be hard, and this would be a fragile solution should new
	--	code be added which creates a WFCatUser and doesn't close it, we'd be in a bad
	--	way again. What we really need is for the operations that currently happen
	--	in init() for doc store and index store to be made restartable, via a message
	--	that can be sent after haltAndDeleteP which puts it back into working form.

	return oAuth2SessionForSelf( self ):logout()

end

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

local function determineCatalogSubtypes( optCatalogSubtype )

	local subtypeForLookup, subtypeForCreate = 'lightroom%3BX-lightroom', 'lightroom'

	if optCatalogSubtype then

		subtypeForLookup = optCatalogSubtype
		subtypeForCreate = optCatalogSubtype

	elseif WFConfiguration.Nimbus.useV1Catalog == false then

		subtypeForLookup = 'lightroom_v2'
		subtypeForCreate = 'lightroom_v2'

	end

	return subtypeForLookup, subtypeForCreate
end

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

-- @mark catalogs

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

--[[!
	\brief Creates a new catalog on Oz.

	\param name (string) name for catalog
	\param optCatalogSubtype (string) subtype to use for catalog (default = 'lightroom')

	\return Observable< WFCatSpec > a description of the catalog that was created
]]--

function WFCatUser:createCatalog( name, optCatalogSubtype )

	check( 'WFCatUser', '+string', '?+string' )

	local newCatalogId = KSIdGenerator.uuidInLowercaseHex()

	local newCatalogUrl = WFURI.resolve( self:serverBaseUrl(),
										 '/v2/catalogs/' .. newCatalogId )

	local _, subtype = determineCatalogSubtypes( optCatalogSubtype )

	logger:infof( "creating new catalog ID %s subtype %s",
				  newCatalogId, subtype )

	local newCatInfo = KSJsonEncoder.encodeOzUploadJson {
		payload = {
			name = name,
		},
		subtype = subtype,
	}

	local putOptions = {
		requestHeaders = {
			{ header = 'Content-Type', value = 'application/json' },
		},
		requestMethod = 'PUT',
	}

	return self:put( newCatalogUrl, newCatInfo, putOptions )

		:flatMap( function( _, headers )

			logger:debugf( "created new catalog ID %s", newCatalogId )

			assert( type( headers ) == 'table' )
			assert( headers.status_code == '201' )

			local location = headers.Location
			assert( type( location ) == 'string' )

			return self:get( location )
				:map( KSJsonDecoder.decodeWebSafeJson )
				:map( function ( catalogDoc )
					return WFCatSpec( self, catalogDoc )
				end )
				:catch( function()
					return Observable.just( WFCatSpec( self, newCatalogId ) )
				end )

		end )

		:catch( function( message, headers )

			logger:warnf( "FAILED TO CREATE NEW CATALOG ID %s because %s",
						   newCatalogId, message )

 			if headers and
					headers.status_code == '403' and
					headers[ 'X-Result-Subtype' ] == kTooManyCatalogsError then

				-- WF-1316: It's possible for another client to create a catalog
				-- in between the time we detect that no catalog exists, and the
				-- time that we attempt to create one. In this case, Oz returns
				-- a "TooManyCatalogsError" and we instead use the catalog
				-- created by the other client.

				return self:findFirstCatalogWithSubtype( subtype )

					:flatMapLatest( function( maybeWFCatSpec )
						if maybeWFCatSpec then
							return Observable.just( maybeWFCatSpec )
						else
							return Observable.throw( message )
						end

					end )

			else
				return Observable.throw( message )
			end

		end )

end

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

--[[!
	\brief Find the first catalog (if any) of a given subtype

	\param (string) subtype of the catalog
	\param (string) optional role (e.g. owner, user, any)

	\return Observable< WFCatSpec > a description of the catalog that was found or created
]]--

function WFCatUser:findFirstCatalogWithSubtype( subtype, optRole )

	check( 'WFCatUser', '+string' )

	local subtypeParam = string.format( 'subtype=%s', subtype )

	local roleParam  = ''
	if optRole then
		roleParam = string.format( '&role=%s', optRole )
	end

	local catalogsUrl = WFURI.resolve( self:serverBaseUrl(),
									   string.format( '/v2/catalogs?%s%s', subtypeParam, roleParam ) )

	return self:get( catalogsUrl )

		:map( KSJsonDecoder.decodeWebSafeJson )

		-- TODO [scouten 2015-07-24]: What if we fail here?
		-- Remember previous catalog? Create a temporary placeholder catalog?
		-- Note that in NmbUserModel, we save off the managed catalog ID in
		-- _localOnly.managedCatalogId. Maybe use that here?

		:map( function( catalogList )

			logger:debugf( "catalog list was %s", catalogList )

			local catalogTable = KSTableUtils.derefOpt( catalogList, 'resources', 1 )

			if catalogTable then

				local catalogId = catalogTable.id
				local catalogSubtype = catalogTable.subtype

				logger:infof( "selected catalog ID %s subtype %s", catalogId, catalogSubtype )

				-- if there's a base URI (there should be) send it along
				catalogTable.base = catalogList.base

				return WFCatSpec( self, catalogTable )
			else
				return nil
			end
		end )
end

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

--[[!
	\brief Find or create a catalog for this user.

	\param name (string) name for catalog (if creating catalog)
	\param optCatalogSubtype (string) subtype to use for catalog (default = 'lightroom')

	\return Observable< WFCatSpec > a description of the catalog that was found or created
]]--

function WFCatUser:findOrCreateCatalog( name, optCatalogSubtype )

	check( 'WFCatUser', '+string', '?+string' )

	local subtypeForLookup, subtypeForCreate = determineCatalogSubtypes( optCatalogSubtype )

	local findOrCreateCatalogCountForOzUser = findOrCreateCatalogCountByOzUserId[ self._ozUserId ] or 0
	findOrCreateCatalogCountForOzUser = findOrCreateCatalogCountForOzUser + 1
	findOrCreateCatalogCountByOzUserId[ self._ozUserId ] = findOrCreateCatalogCountForOzUser

	return Observable.just( findOrCreateCatalogCountForOzUser )

		:flatMap( function( count )

			if count > 1 then

				-- WF-1430: Oz has reported that clients can issue multiple calls to create
				-- catalogs in quick succession. The cause of this is unknown, though is
				-- seems possible that client code could be triggering the repeated calls
				-- to findOrCreateCatalog resulting in this behavior. As a preventative measure,
				-- we count the number of times this function is called. If it's the first time
				-- we are calling this function, it executes without delay. On subsequent calls,
				-- we add in a 5 second delay before we check to see if the catalog exists on Oz;
				-- this delay should allow for any prior catalog creation requests to complete
				-- and therefore prevent us from creating multiple catalogs.

				-- This solution only guarantees a delay the second time WFCatUser:findOrCreateCatalog
				-- is called. This should be okay for WF-1430, as Oz only ever sees two successive
				-- catalog creation events.

				-- Because the counts are tracked by user Id, the delay will only exist if the
				-- same user makes multiple requests. This should not affect workflows relating to
				-- logging out and then in as a different user.

				local CATALOG_CREATION_DELAY = 5

				return Observable.timer( CATALOG_CREATION_DELAY, WFRxScheduler.sharedScheduler() )

			else
				return Observable.just( true )
			end

		end )

		:take( 1 )

		:flatMap( function()

			return self:findFirstCatalogWithSubtype( subtypeForLookup )

		end )

		:flatMapLatest( function( maybeWFCatSpec )

			if maybeWFCatSpec then
				return Observable.just( maybeWFCatSpec )
			else
				return self:createCatalog( name, subtypeForCreate )
			end

		end )

end

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

--[[!
	\brief Find first Lightroom catalog for this user.

	\return Observable< WFCatSpec > a description of the catalog that was found
]]--

function WFCatUser:findFirstLightroomCatalog()

	local subtypeForLookup = determineCatalogSubtypes()

	return self:findFirstCatalogWithSubtype( subtypeForLookup )

end

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

--[[!
	\brief Apply account document update to the user info cache

	\param docValue (table) full account docValue to use to update the user info cache

	\return Observable
]]--

function WFCatUser:applyAccountUpdate( accountDocValue )

	local cache = self:userInfoCache()
	local userId = self:userId()

	return cache:docValue( userId )

		:flatMap( function( docValue )

			-- calculate the necessary overlay to get from the previous user account value
			-- to the latest user account value from Oz
			local userAccountDocOverlay = WFTableMerge.createOverlay( docValue, accountDocValue )

			userAccountDocOverlay = sanitizeOverlay( userAccountDocOverlay )

			-- apply the overlay to the doc store so that it matches the latest doc value
			-- from Oz
			userAccountDocOverlay._id = userId

			return cache:applyOverlay( userAccountDocOverlay )

		end )
end

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

--[[!
	\brief Get account url for this user

	\return string
]]--

function WFCatUser:getAccountUrl()

	return WFURI.resolve( self:serverBaseUrl(), string.format( "/v2/accounts/%s", self:userId() ) )

end

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

--[[!
	\brief Get account document from Oz for this user

	\return Observable< table > returns the user account document from Oz
]]--

function WFCatUser:getAccountDocFromOz()

	local accountUrl = self:getAccountUrl()

	local options = {
		bypassRateLimiter = true
	}

	return self:get( accountUrl, options )
		:flatMap( function( userAccountJSON )

			local userAccountDoc = KSJsonDecoder.decodeWebSafeJson_stripNull_ignoreEmpty( userAccountJSON )

			if userAccountDoc then

				return self:applyAccountUpdate( userAccountDoc )
						:map( function()
							return userAccountDoc
						end )

			else
				return Observable.just( userAccountDoc )
			end
		end )

end

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

--[[!
	\brief Get account settings from Oz for this user

	\return Observable< table > returns the user account settings from Oz
		{
			universal = { ... },
			lightroom = { ... },
			lightroom_mobile = { ... },
			lightroom_web = { ... },
			photosdk = { ... }
		}
]]--

function WFCatUser:getAccountDocSettingsFromOz()

	return self:getAccountDocFromOz()
		:pluck( 'payload' )

end

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

--[[!
	\brief Update account document settings to local cache and Oz for this user.
		We only allow below sections applied to accountDoc.payload
		{
			universal = {},
			lightroom = {},
			lightroom_mobile = {},
			photosdk = {}
		}

	\return Observable< table >
]]--

function WFCatUser:updateAccountDocSettings( payload )

	check( 'WFCatUser', 'table' )

	if not next( payload ) then
		return Observable.just( {} )
	end

	local payloadTable = {
		universal = payload.universal,
		lightroom = payload.lightroom,
		lightroom_mobile = payload.lightroom_mobile,
		photosdk = payload.photosdk
	}

	local payloadJson = KSJsonEncoder.encodeOzUploadJson( payloadTable )

	local requestHeaders = {
		requestMethod = 'POST',
		returnHttpErrorsAsData = true,
		requestHeaders = {
			{
				header = "Content-Type",
				value = "application/json",
			},
		}
	}

	return self:put( string.format( "%s/settings", self:getAccountUrl() ),
					payloadJson, requestHeaders )
		:flatMap( function()
			local overlay = {
				_id = self._ozUserId,
				payload = payloadTable
			}

			return self:userInfoCache():applyOverlay( overlay )
		end )

end
--------------------------------------------------------------------------------

--[[!
	\brief Get the country code within the account document from Oz for this user

	\return Observable< table > returns the country code from Oz
]]--

function WFCatUser:getCountryCodeForUser()

	return self:getAccountDocFromOz()
		:catch( Observable.just( {} ) )
		:pluck( 'country' )
		:take( 1 )

end

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

--[[!
	\brief Enable an account feature for this user.

	\param featureName (string) name of the Oz feature to enable (e.g. 'search_metadata')

	\return Observable< boolean > returns true if successful
]]--

function WFCatUser:enableFeature( featureName )

	check( 'WFCatUser', '+string' )

	local featureUrl = string.format( '/v2/accounts/%s/feature_classes/%s', self:userId(), featureName )

	if WFPlatformInfo.isUnitTest() then
		featureUrl = WFURI.resolve( 'https://admin.adbephotos-stage.com', featureUrl )
	end

	local putOptions = {
		requestHeaders = {
			{ header = 'Content-Type', value = 'application/json' },
		},
		requestMethod = 'PUT',
		returnHttpErrorsAsData = true,
	}

	return self:put( featureUrl, "{}", putOptions )

			:map( function( maybeErrorMessage, headers )

				local httpCode = headers.status_code

				if ( httpCode ~= '200' and httpCode ~= '201' ) then
					logger:warnf( "Unable to enable account feature %s", featureName )
					return false
				end

				return true

			end )

			:catch( function( maybeErrorMessage )

				logger:warnf( "Unable to enable account feature %s", maybeErrorMessage or 'unknown' )
				return Observable.just( false )

			end )

end

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

--[[!
	\brief Disable an account feature for this user.

	\param featureName (string) name of the Oz feature to enable (e.g. 'search_metadata')

	\return Observable< boolean > returns true if successful
]]--

function WFCatUser:disableFeature( featureName )

	check( 'WFCatUser', '+string' )

	local featureUrl = string.format( '/v2/accounts/%s/feature_classes/%s', self:userId(), featureName )

	local putOptions = {
		requestHeaders = {
			{ header = 'Content-Type', value = 'application/json' },
		},
		requestMethod = 'DELETE',
		returnHttpErrorsAsData = true,
	}

	return self:put( featureUrl, "{}", putOptions )

			:map( function( maybeErrorMessage, headers )

				local httpCode = headers.status_code

				if ( httpCode ~= '204'  ) then
					logger:warnf( "Unable to disable account feature %s", featureName )
					return false
				end

				return true

			end )

			:catch( function( maybeErrorMessage )

				logger:warnf( "Unable to disable account feature %s", maybeErrorMessage or 'unknown' )
				return Observable.just( false )

			end )

end

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

function WFCatUser:isFaceDetectionAllowed()

	check( 'WFCatUser' )

	return self:userInfoCache():ongoingFieldValue( self._ozUserId, 'payload/universal/disallow_face_detection' )
		:map( function( disallow_face_detection )
			if disallow_face_detection then
				return false
			else
				return true
			end
		end )
end

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

function WFCatUser:allowFaceDetection()

	check( 'WFCatUser' )

	local payloadTable = {
		universal = {
			disallow_face_detection = false
		}
	}

	return self:updateAccountDocSettings( payloadTable )

end

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

function WFCatUser:disallowFaceDetection()

	check( 'WFCatUser' )

	local payloadTable = {
		universal = {
			disallow_face_detection = true
		}
	}

	return self:updateAccountDocSettings( payloadTable )

end

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

function WFCatUser:isGenAITermsOfUseAccepted()

	check( 'WFCatUser' )

	return self:userInfoCache():ongoingFieldValue( self._ozUserId, 'payload/universal/genai_accepted' )
		:map( function( genai_accepted )
			if genai_accepted then
				return true
			else
				return false
			end
		end )
end

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

function WFCatUser:setGenAITermsOfUseAccepted( value )

	check( 'WFCatUser' )

	local payloadTable = {
		universal = {
			genai_accepted = value
		}
	}

	return self:updateAccountDocSettings( payloadTable )
		:catch( function( data, headers )
			return Observable.just( { data = data, headers = headers })
		end )
end

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

function WFCatUser:predictionRequestUrlTimeout()

	check( 'WFCatUser' )

	local timeoutComesFromAccountDocument = false

	return self:userInfoCache():docValue( self._ozUserId )
		:map( function( docValue )
			local predictionRequestUrlTimeout = derefOpt( docValue, 'config', 'prediction_request_url_timeout' )

			if( predictionRequestUrlTimeout and type( predictionRequestUrlTimeout ) == 'number' ) then

				timeoutComesFromAccountDocument = true

				return predictionRequestUrlTimeout, timeoutComesFromAccountDocument

			else
				return 15 * 60, timeoutComesFromAccountDocument
			end
		end )

end

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

function WFCatUser:purgeAssetsAfterValue()

	check( 'WFCatUser' )

	return self:userInfoCache():docValue( self._ozUserId )
		:map( function( docValue )
			local purgeAssetsAfter = derefOpt( docValue, 'config', 'purge_assets_after' )

			if( purgeAssetsAfter and type( purgeAssetsAfter ) == 'number' ) then
				return purgeAssetsAfter
			else
				return 60*24*60*60
			end
		end )

end

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

function WFCatUser:sharingPolicies()

	check( 'WFCatUser' )

	return self:userInfoCache():docValue( self._ozUserId )
		:map( function( docValue )
			local invitationSharing = derefOpt( docValue, 'policies', 'invitationSharing' )
			local invitationSharingDisallowed = invitationSharing == 'none'
			local invitationSharingAllowDomains = invitationSharing == 'allowDomains'

			local publicSharingDisallowed = derefOpt( docValue, 'policies', 'publicSharingDisallowed' ) or false
			local requestAccessSharingDisallowed = derefOpt( docValue, 'policies', 'requestAccessSharingDisallowed' ) or false
			local anyoneCanUploadDisallowed = derefOpt( docValue, 'policies', 'anyoneCanUploadDisallowed' ) or false

			local allowDomains
			if invitationSharingAllowDomains then
				allowDomains = derefOpt( docValue, 'policies', 'allowDomains' ) or {}
			end

			local feedbackDisallowed = derefOpt( docValue, 'policies', 'feedbackDisallowed' ) or false
			local communityPostingDisallowed = derefOpt( docValue, 'policies', 'communityPostingDisallowed' ) or false

			return {
				invitationSharingDisallowed = invitationSharingDisallowed,
				invitationSharingAllowDomains = invitationSharingAllowDomains,
				publicSharingDisallowed = publicSharingDisallowed,
				requestAccessSharingDisallowed = requestAccessSharingDisallowed,
				anyoneCanUploadDisallowed = anyoneCanUploadDisallowed,
				allowDomains = allowDomains,

				feedbackDisallowed = feedbackDisallowed,
				communityPostingDisallowed = communityPostingDisallowed
			}
		end )

end

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

local function createOzSupportedLocaleForLanguage( languageCode )

	local languageRegionMapping = {
		de = 'DE',
		es = 'ES',
		fr = 'FR',
		it = 'IT',
		ja = 'JP',
		ko = 'KR',
		pt = 'BR',
		ru = 'RU',
	}

	local index = languageCode:find( '-' )

	if index then
		languageCode = languageCode:sub( 1, index - 1 )
	end

	local deviceRegionCode = WFLocalizationUtils.getRegionCode()

	local regionCode = languageRegionMapping[ languageCode ]

	if not regionCode then

		if languageCode == 'zh' then

			local zhMap = { cn = true, tw = true }

			local zhRegion = zhMap[ deviceRegionCode ]

			if not zhRegion then
				regionCode = 'cn'
			end

		else

			languageCode = WFLocalizationUtils.getLanguageCode()
			regionCode = deviceRegionCode

		end

	end

	return languageCode, regionCode

end

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

local function isCommunicationPreferenceUpdateRequired( wfCatUser )

	return wfCatUser:getAccountDocSettingsFromOz()
		:map( function( userAccountSettings )
			local communicationPreference = KSTableUtils.derefOpt( userAccountSettings,
												'universal',
												'communication_preference' )
			return not communicationPreference
		end )

end

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

function WFCatUser:updateCommunicationPreference( optLocale )

	check( 'WFCatUser', '?string' )

	return isCommunicationPreferenceUpdateRequired(self)
		:flatMap( function ( updateRequired )

			if not updateRequired then
				return Observable.just( nil )
			end

			if optLocale then
				optLocale = string.gsub( optLocale, '_', '-' )
			else
				local languageCode, regionCode = createOzSupportedLocaleForLanguage(
										WFLocalizationUtils.getLanguageCode() )

				if not languageCode or not regionCode then

					if not languageCode and not regionCode then
						logger:warnf( "updating communication preference failed: languageCode and regionCode missing" )
					elseif not regionCode then
						logger:warnf( "updating communication preference failed: regionCode missing" )
					else
						logger:warnf( "updating communication preference failed: languageCode missing" )
					end

					return Observable.just( nil )

				end

				optLocale = string.format( '%s-%s', languageCode, regionCode )
			end

			local settingsPayload = {
				universal = {
					communication_preference = optLocale,
				}
			}

			return self:updateAccountDocSettings( settingsPayload )
			:tap( function()
				logger:infof( "updated communication preferences to %s", optLocale )
			end,
			function( message, headers )
				logger:warnf( "updating communication preference failed message %s\n\nheaders %s\n\n", message, headers )
			end )
			:map( function()
				return optLocale
			end)

		end)

end

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

function WFCatUser:mapPartialUrlToCompleteUrl( partialUrl )

	return mapPartialUrlToCompleteUrl( self, self:serverBaseUrl(), partialUrl )

end

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

local WFCatUser_class = Rx.Class 'WFCatUser'( WFCatUser )

local function validator( _, _, userId )

	return userId

end

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

return WFObjectProxy( 'WFCatUser', WFCatUser_class, validator, true )
