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

WFOzUserLogin.lua

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

ADOBE CONFIDENTIAL
------------------
Copyright 2015 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.

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

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

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

	-- WF.core.rxlua
local Rx = require 'Rx'
local Observable = Rx.Observable
local WFRxRetryObservable = require 'WFRxRetryObservable'
local WFRxRunUntilDone = require 'WFRxRunUntilDone'

	-- WF.core.utils
local WFArguments = require 'WFArguments'

	-- WF.oz_client.account
local WFOzClient = require 'WFOzClient'

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

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

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

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

local KSDate = require 'KSDate'


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

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

local check = assert( WFArguments.check )

local runUntilDone = WFRxRunUntilDone

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

-- @mark Error definitions

local kError_ClientError = "client_error"
local kError_ServerError = "server_error"
local kError_ConnectionError = "connection_error"
local kError_Failure = "failure"

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

-- @mark WFOzUserLogin namespace

local WFOzUserLogin = {}

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

local function throwErrorWithCode( code, data, retryDelay, rawErrStr, ... )

	-- SPAM [jandrew 2021-01-21]: PLEASE READ THIS:
	-- The format and contents of errorMessage should NOT be changed.
	-- Client teams parse this string, so any change to the current format may
	-- break client error handling.

	local errorMessage

	if type( rawErrStr ) == "string" and select( "#", ... ) > 0 then

		-- assume rawErrStr is a printf style format str

		local ok, maybeFormattedErrStr = 
				pcall( string.format, rawErrStr, ... )

		errorMessage = 
			ok and 
				maybeFormattedErrStr or
				string.format( "Bad formatted error string: %s. Formatted error string problem: %s",
								rawErrStr, maybeFormattedErrStr )

	end

	errorMessage = 
		errorMessage or 
		( rawErrStr and 
			tostring( rawErrStr ) or
			string.format( "Error code '%s' didn't have error string. It was %s", 
							code, rawErrStr ) )

	local maybeErrorTypeIsRetryable

	if code == kError_ClientError or code == kError_Failure then

		maybeErrorTypeIsRetryable = false

	elseif code == kError_ServerError or code == kError_ConnectionError then

		maybeErrorTypeIsRetryable = true

	else

		logger:warnf( "throwErrorWithCode( '%s', ... ) has unknown retry state", code )

	end

	return Observable.throw( errorMessage, code, maybeErrorTypeIsRetryable, retryDelay, data )

end

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

local function getRetryDelay( responseHeaders )

	responseHeaders = responseHeaders or {}

	return tonumber( responseHeaders[ 'Retry-After' ] )

end

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

local function makeHandleHttpResultStream( httpReqDesc )

	-- return a function that can :map() results from WFRxHttpClient.put() or
	-- WFRxHttpClient.get()
	--
	-- The function should throw unless the request emits headers containing
	-- 200 <= statusCode < 300

	return function( data, headers )

		if type( headers ) ~= "table" then

			return throwErrorWithCode( 
						kError_Failure,
						data,
						nil,
						"%s http error. No headers table in response. Headers: %s, Data: %s",
						httpReqDesc, headers, data )

		end

		local statusCode = tonumber( headers.status_code )

		if not statusCode then

			return throwErrorWithCode( 
						kError_Failure,
						data,
						nil,
						"%s http error. No status code in response headers. headers.status_code: %s, Data: %s",
						httpReqDesc, headers.status_code, data )

		end
		
		if statusCode >= 200 and statusCode < 300 then

			return Observable.just( data, headers )

		end

		local retryDelay = getRetryDelay( headers )

		if statusCode >= 400 and statusCode < 500 then

			return throwErrorWithCode( 
					kError_ClientError,
					data,
					retryDelay,
					"%s http status %s error: %s", 
					httpReqDesc, statusCode, data )

		end
		
		return throwErrorWithCode( 
					kError_ServerError,
					data,
					retryDelay,
					"%s http status %s error: %s", 
					httpReqDesc, statusCode, data )

	end

end

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

local function loginFromTokens_impWithVersionSwitch( baseServerUrl, version,
													 accessToken, refreshToken,
													 optDeviceToken )
	local startTimer = KSDate.timeIntervalSinceAppLaunch()
	local totalTimeSpentInOzAuthentication, totalTimeTakenForOzRegistrationCall, timeWhenOzhandshakeWasDone
	
	logger:infof( "loginFromTokens( version = %s, accessToken = %s, refreshToken = %s, "
						.. "optDeviceToken = %s )",
						version, accessToken, refreshToken, optDeviceToken )

	if optDeviceToken then
		require 'KSKeychainService'.writeP( 'ims_device_token', optDeviceToken )
	end

	local imsClientId = WFImsAppIdentity.getClientIdAndSecret() -- we don't care about secret

	local registrationPostParams = {
		requestMethod = 'POST',
		requestHeaders = {
			{ header = 'Authorization', value = 'Bearer ' .. accessToken },
			{ header = 'X-Api-Key', value = imsClientId },
			{ header = 'Content-Type', value = 'application/json' },
		},
		returnHttpErrorsAsData = true,
	}

	local registrationUrl = WFURI.resolve( baseServerUrl, string.format( '/%s/registrations', version ) )

	return WFRxHttpClient.put( registrationUrl, '{}', registrationPostParams )
		:catch( function( err, responseHeaders )

			-- SPAM [ashj 2017-07-19]: We need to differentiate
			-- network failures from actual login failures.
			-- Hence adding a code/string to the error
			-- received.
			
			return throwErrorWithCode( kError_ConnectionError, err, getRetryDelay( responseHeaders ),
				"Error with registration PUT: %s", tostring( err ) )

		end )

		:tap( function( message )
			totalTimeTakenForOzRegistrationCall = KSDate.timeIntervalSinceAppLaunch() - startTimer	
			logger:infof( "registration call succeeded with message = %s", message )
		end,
		function( message )
			logger:errorf( "registration call failed with message = %s", message )
		end )
		
		:flatMap( makeHandleHttpResultStream( "Token based access Registration PUT" ) )

		:map( function( )

			local options = {
				requestHeaders = {
					{ header = 'Authorization', value = 'Bearer ' .. accessToken },
					{ header = 'X-Api-Key', value = imsClientId },
		--			{ header = 'x-token-ttl', value = '30' },
						-- [scouten 2011-07-01]: Normally this should be commented out.
						-- Useful when testing the refresh logic. This specifies the lifetime
						-- of the access token in seconds.
				},
				returnHttpErrorsAsData = true,
			}
			
			return WFRxHttpClient.get( WFURI.resolve( baseServerUrl,
													  string.format( '/%s/accounts/00000000000000000000000000000000', version ) ),
									   options )
				:catch( 
					function( err, responseHeaders )

					-- SPAM [ashj 2017-07-19]: We need to differentiate
					-- network failures from actual login failures.
					-- Hence adding a code/string to the error
					-- received.
					
					return throwErrorWithCode( kError_ConnectionError, err, getRetryDelay( responseHeaders ),
						"Error with Account GET: %s", err )

				end )
				:flatMap( makeHandleHttpResultStream( "Token based access account GET" ) )

		end )

		:switch()
		
		:map( KSJsonDecoder.decodeWebSafeJson_stripNull )

		:catch(
			function( errStr, ... )

				-- 2020.08.25 bkruse
				--
				-- Previous code did not catch json decode errors. This code
				-- catches it and logs it, but rethrows error as is instead of using
				-- throwErrorWithCode() so that legacy behavior is preserved.
				local formattedErrStr = "Error decoding JSON account info: %s" .. 
											string.rep( ", %s", select( "#", ... ) )

				logger:errorf( formattedErrStr, errStr, ... )

				return Rx.Observable.throw( errStr, ... )

			end )

		:flatMapLatest( function( userInfo )

			local userId = userInfo and userInfo.id and userInfo.id:sub( -32 )

			if userId then
				timeWhenOzhandshakeWasDone = KSDate.timeIntervalSinceAppLaunch()
				totalTimeSpentInOzAuthentication = timeWhenOzhandshakeWasDone - startTimer
				logger:infof( "token corresponds to user %s", userInfo )
			else
				logger:errorf( "info packed from Oz was missing user ID\n\n%s\n\n", userInfo )
				return throwErrorWithCode( kError_Failure, nil, nil, "info packet from Oz was missing user ID")
			end
			
			local perfData = {
				description = "WFOzAuthentication perf data.",
				totalTimeSpentInOzAuthentication = totalTimeSpentInOzAuthentication,
				totalTimeTakenForOzRegistrationCall = totalTimeTakenForOzRegistrationCall,
				timeWhenOzhandshakeWasDone = timeWhenOzhandshakeWasDone
			}
			
			return Observable.just( userId, userInfo, baseServerUrl, accessToken, refreshToken, optDeviceToken, perfData )

		end )

end

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

local function loginFromTokens_impV2( baseServerUrl, accessToken, refreshToken,
									  optDeviceToken )

	return loginFromTokens_impWithVersionSwitch( baseServerUrl, 'v2',
												 accessToken, refreshToken,
												 optDeviceToken )

end

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

local function returnRxUserAccount( userId,
									userInfo,
									baseServerUrl,
									userInfoCacheDir,
									accessToken,
									refreshToken,
									optDeviceToken,
									perfData )

	-- Spin up WFCatUser account and record the initial user info packet.

	local wfCatUser = require 'WFCatUser'( baseServerUrl, userId, userInfoCacheDir )

	logger:debugf( "\n\nuserInfoCachePath = %s\n\n", wfCatUser:userInfoCachePath() )
	logger:debugf( "\n\nwriting info packet to userInfoCache %s\n\n", userInfo )
	
	userInfo._id = userId
	
	return wfCatUser:applyAccountUpdate( userInfo )
		:map( function()
			return wfCatUser, userInfo, accessToken, refreshToken, optDeviceToken, perfData
				-- TODO [scouten 2015-07-22]: Five args is a bit unwieldy.
				-- Change to a table?
				-- TODO [jandrew 2018-07-27]: Now it's 6! Sorry Eric.
		end )

end

--------------------------------------------------------------------------------
--[[!
	\brief Given a set of IMS tokens, establishes a connection to Oz for V2 APIs.

	Call when a user has successfully authenticated against the IMS server.
	Triggers a call to the Oz server to obtain further user information
	(such as user name, subscription status, and so on).

	\param userInfoCacheDir (string) The directory to store the user info cache
	\param baseServerUrl (string) The base URL for the Oz server to use.
	\param accessToken (string) The new access token.
	\param refreshToken (string) The new refresh token.
	\param optDeviceToken (string or nil) The device token, if applicable

	\return Observable< WFCatUser, info packet > user object and table containing user info
]]--

function WFOzUserLogin.loginFromTokens( userInfoCacheDir, baseServerUrl, accessToken,
										refreshToken, optDeviceToken )

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

	return loginFromTokens_impV2( baseServerUrl, accessToken, refreshToken, optDeviceToken )

		:map( function( userId, userInfo, baseServerUrl, accessToken, refreshToken, optDeviceToken, perfData )
		
			return returnRxUserAccount( userId,
									userInfo,
									baseServerUrl,
									userInfoCacheDir,
									accessToken,
									refreshToken,
									optDeviceToken,
									perfData )
			
		end )
		:switch()

end

--------------------------------------------------------------------------------
--[[!
	\brief Given an IMS email and password pair, obtain login tokens.

	Should be used only for testing.

	\param baseServerUrl (string) The base URL for the Oz server to use.
	\param version (string) API version set to use (should be either "v1.0" or "v2")
	\param email (string) user's e-mail address
	\param password (string) user's password

	\return Observable< baseServerUrl, accessToken, refreshToken >
]]--

function WFOzUserLogin.tokensFromEmailAndPassword( baseServerUrl, version, email, password )

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

	logger:infof( "WFOzUserLogin.tokensFromEmailAndPassword( baseServerUrl = %s, version = %s, ... )"
				  .. "\n\ncalled via %s\n\n",
				  baseServerUrl, version, ':traceback:' )

	if version ~= 'v1.0' and version ~= 'v2' then
		error( "WFOzUserLogin.tokensFromEmailAndPassword: version must be either 'v1.0' or 'v2'", 2 )
	end

	return WFRxRetryObservable {

		maxAttempts = 3,
		retryDelay = 5,

		factory = function()

			local language_code = WFLocalizationUtils.getLanguageCode()
			local region_code = WFLocalizationUtils.getRegionCode()

			local locale = string.format( "%s_%s", language_code, region_code )

			local clientId, clientSecret = WFImsAppIdentity.getClientIdAndSecret()

			local httpParams = {
				username = email,
				password = password,
				redirect_uri = 'air-auth://carousel',
				scope = 'openid,AdobeID',
				locale = locale,
				client_id = clientId,
				client_secret = clientSecret,
			}

			return WFOzClient.getImsAuthUrl { baseServerUrl = baseServerUrl, apiVersion = version }

				:selectMany( function( imsAuthUrl, imsBaseUrl )

					local url = string.format( '%s/login/code', imsBaseUrl )
					local postBody = WFURI.buildQueryString( httpParams )

					local postArgs = {
						requestMethod = 'POST',
						requestHeaders = {
							{ header = 'Content-Type', value = 'application/x-www-form-urlencoded' },
							{ header = 'X-IMS-ClientID', value = clientId },
						},
						returnHttpErrorsAsData = true,
					}

					return WFRxHttpClient.put( url, postBody, postArgs )

				end )
				
				:catch( function( err, responseHeaders )

					-- SPAM [ashj 2017-07-19]: We need to differentiate
					-- network failures from actual login failures.
					-- Hence adding a code/string to the error
					-- received.
					
					return throwErrorWithCode( kError_ConnectionError, err, getRetryDelay( responseHeaders ), err )
				end )
				
				:flatMap( makeHandleHttpResultStream( "Email based login PUT" ) )

				:map( KSJsonDecoder.decodeWebSafeJson )

				:flatMap( function( json )

					if json.error then
						return throwErrorWithCode( kError_Failure, nil, nil, "IMS error: %s", json.error )
					end

					if json.access_token and json.refresh_token then
						return Observable.just( baseServerUrl, json.access_token, json.refresh_token )
					else
						return throwErrorWithCode( kError_Failure, nil, nil, "Tokens missing: %s", json )
					end

				end )

		end,

	}

	:take( 1 )

end

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

local function loginFromEmailAndPassword_imp( baseServerUrl, version,
											  email, password )

	logger:infof( "Attempting login for user %s", email )

	return WFOzUserLogin.tokensFromEmailAndPassword( baseServerUrl, version, email, password )

		:selectMany( function( baseServerUrl, accessToken, refreshToken )
			return loginFromTokens_impWithVersionSwitch( baseServerUrl, version, accessToken, refreshToken )
		end )

		:call( runUntilDone )

end

--------------------------------------------------------------------------------
--[[!
	\brief Login using an e-mail address and password for V2 APIs.

	Should be used only for testing.

	\param userInfoCacheDir (string) The directory to store the user info cache
	\param baseServerUrl (string) The base URL for the Oz server to use.
	\param email (string) user's e-mail address
	\param password (string) user's password

	\return Observable< WFOzUser, info packet > user object and table containing user info
]]--

function WFOzUserLogin.loginFromEmailAndPassword( userInfoCacheDir, baseServerUrl, email, password )

	return loginFromEmailAndPassword_imp( baseServerUrl, 'v2', email, password )

		:selectMany( function( userId, userInfo, baseServerUrl, accessToken, refreshToken, optDeviceToken, perfData )
		
			return returnRxUserAccount( userId,
									userInfo,
									baseServerUrl,
									userInfoCacheDir,
									accessToken,
									refreshToken,
									optDeviceToken,
									perfData )
			
		end )

end

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

return WFOzUserLogin
