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

WFOAuth2SessionImp.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.

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

This object maintains the state for a single OAuth2 user session. A separate
message processor with this object is created for each session to avoid the
problems associated with communicating mutable state across threads.

This object should only be accessed via WFOAuth2Session from a client thread.

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

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

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

	-- WF.core.lua
local KSData = require 'KSData'
local WFRequestDispatcher = require 'WFRequestDispatcher'

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

	-- WF.core.rxlua
local WFRxRetryObservable = require 'WFRxRetryObservable'
local WFRxScheduler = require 'WFRxScheduler'
local WFRxNativeObservable = require 'WFRxNativeObservable'
local WFRxRunUntilDone = require 'WFRxRunUntilDone'

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

	-- WF.oz_client.ims
	-- TODO [arahn 2014-05-13]: This is an inverted dependency!
local WFImsAppIdentity = require 'WFImsAppIdentity'
local WFOzClient = require 'WFOzClient'

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

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

local check = assert( WFArguments.check )

local runUntilDone = WFRxRunUntilDone

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

local logger = WFLogger 'WFOAuth2SessionImp'
-- :setLevel( 'DEBUG' )

local requestNewTokens -- forward declaration

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

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

-- @mark WFOAuth2SessionImp class

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

local WFOAuth2SessionImp = {

	_sessionId = false,
		-- (string) client ID (typically Oz-style UUID)

	_serverBase = false,
		-- (string) base URL for Oz server being used (e.g. "https://photos-stage.adobe.io")

	_accessAndRefreshTokens = false,
		-- (ReplaySubject< accessToken, refreshToken, optDeviceToken >)

	_validAccessAndRefreshTokens = false,
		-- (Observable< accessToken, refreshToken, optDeviceToken >)

	_refreshRequests = false,
		-- (Subject< table with accessToken, refreshToken, optDeviceToken, authHeader >)

	_refreshNeeded = false,
		-- (Observable< accessToken, refreshToken, optDeviceToken >)

	_refreshSubscribeHandle = false,
		-- (Disposable) for closing down _refreshNeeded subscription

	_requestNewTokensSubscribeHandle = false,
		-- (Disposable) for closing down requestNewTokens flow if in flight

	_hasValidLogin = false,
		-- (ReplayObservable< boolean >)

	_languagePrefString = false,
		-- (string) cached language preference string

	_httpStack = false,
		-- (table) namespace for HTTP requests (can be replaced for debugging)
		
	_nativeTokenFreshProviderModule = false,
		-- (table) Lua module that provides a WFRxNativeObservable matching function
		-- to do the IMS token refresh

	WFCloseSupport.addCloseSupportMethods,

}

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

function WFOAuth2SessionImp:__init( sessionIdAndMaybeServerBase, nativeTokenRefreshModule )

	check( 'WFOAuth2SessionImp', '+string', 'table|nil' )

	local i, j = string.find( sessionIdAndMaybeServerBase, ':::', 1, true )

	if i then
	
		self._sessionId = string.sub( sessionIdAndMaybeServerBase, 1, i - 1 ) 
		self._serverBase = string.sub( sessionIdAndMaybeServerBase, j + 1 )
		
		if string.len( self._serverBase ) == 0 then
			self._serverBase = nil
		end
		
	else
	
		self._sessionId = sessionIdAndMaybeServerBase
	
	end

	
	logger:infof( "Created session imp ID %s via %s", self._sessionId )

	self._httpStack = require 'WFRxHttpClient'
	
	self._nativeTokenFreshProviderModule = nativeTokenRefreshModule

	self._accessAndRefreshTokens = ReplaySubject( 1 )

	-- This Observable should have an available value anytime
	-- there is a valid access + refresh token pair and should stall
	-- when there's not.

	self._validAccessAndRefreshTokens = self._accessAndRefreshTokens
		:filter( function( accessToken )
			local isValid = accessToken ~= 'error' and accessToken ~= 'refresh'
			logger:debugf( "_accessAndRefreshTokens updated, isValid = %s", isValid )
			return isValid
		end )

	-- This ReplayObservable derives from the accessTokens storage
	-- and can be used by the outside world to determine whether
	-- there is a valid session.

	self._hasValidLogin = self._accessAndRefreshTokens
		:filter( function( accessToken )
			return accessToken ~= 'refresh'
		end )
		:map( function( accessToken )
			return accessToken ~= 'error'
		end )
		:startWith( true )
		:tap( function( hasValidLogin )
			logger:debugf( "_hasValidLogin = %s", hasValidLogin )
		end )
		:distinctUntilChanged()

	-- This Subject should be triggered whenever we receive a 401
	-- (invalid token) response, regardless of whether the token is
	-- valid or not.

	self._refreshRequests = Subject()

	-- This Observable should fire whenever we need to ask the OAuth2
	-- identity server for a new access token.

	self._refreshNeeded = self._refreshRequests

		-- Merge refresh request with current known-valid token stream.

		:tap( function()
			logger:debugf( "_refreshRequests received an update" )
		end )

		:withLatestFrom( function( refreshRequest, accessToken, refreshToken, optDeviceToken )
			return refreshRequest, accessToken, refreshToken, optDeviceToken
		end, self._validAccessAndRefreshTokens )

		:filter( function( refreshRequest, accessToken, refreshToken, optDeviceToken )
			return type( refreshRequest ) == 'table' and refreshRequest.accessToken == accessToken
		end )

	self._refreshSubscribeHandle = self._refreshNeeded
		:subscribe( function( refreshRequest, accessToken, refreshToken, optDeviceToken )
			return requestNewTokens( self, refreshRequest, accessToken, refreshToken, optDeviceToken )
		end )

	-- Read initial keys. It's possible that there will be a race condition
	-- in which somebody provides the keys shortly *after* this OAuth2 session
	-- is created. To mitigate this risk, we make two attempts to read the
	-- keys a few seconds apart.

	local readKeychainHandle = WFRxRetryObservable {

		maxAttempts = 4,
		retryDelay = 1,

		factory = function()

			return KSKeychainService.read( self._sessionId )

				:map( function( rawJson )

					local decodedJson = KSJsonDecoder.decodeWebSafeJson_stripNull( rawJson )

					if type( decodedJson ) == 'table' then
						logger:infof( "Found existing access and refresh token in keychain" )
						return decodedJson.access_token, decodedJson.refresh_token
					end

				end )

				:selectMany( function( accessToken, refreshToken )

					return KSKeychainService.read( self._sessionId .. '.device_token' )
						:map( function( deviceToken )

							return accessToken, refreshToken, deviceToken

						end )

				end )

				:filter( function( accessToken, refreshToken, deviceToken )

					if not ( accessToken and refreshToken ) then
						error "No tokens in keychain. Do not panic: This may be normal."
					end
					
					return true

				end )

				:tap(
					function( ... )
						logger:debugf( "Got data from keychain" )
					end,
					function( ... )
						logger:errorf( "Failed to get data from keychain: %s, %s, %s", ... )
					end,
					function()
						logger:debugf( "Keychain read completed" )
					end )

		end,

	}
	:subscribe(

		function( accessToken, refreshToken, optDeviceToken )

			logger:infof( "Updating _accessAndRefreshTokens from stored" )

			self._accessAndRefreshTokens:onNext( accessToken, refreshToken, optDeviceToken )

		end,

		function( message )

			logger:errorf( "Forcing logout because %s", message )

			self._accessAndRefreshTokens:onNext( 'error', "no tokens stored" )

		end )

	-- Cancel the above operation if we get tokens from somewhere else.

	self._accessAndRefreshTokens

		:take( 1 )

		:tap( function()

			logger:infof( "Reading tokens from keychain canceled because we got tokens via %s",
						  ':traceback:' )

			readKeychainHandle:dispose()

		end )
		
		:call( runUntilDone )
		
end

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

function WFOAuth2SessionImp:close()

	if self._refreshRequests then
		self._refreshRequests:onCompleted()
		self._refreshRequests:dispose()
		self._refreshRequests = nil
	end

	if self._refreshSubscribeHandle then
		self._refreshSubscribeHandle:dispose()
		self._refreshSubscribeHandle = nil
	end

	if self._requestNewTokensSubscribeHandle then
		self._requestNewTokensSubscribeHandle:dispose()
		self._requestNewTokensSubscribeHandle = nil
	end

end

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

local kUnchanged = {}

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

-- returns an observable that only signals completion
local function updateKeychain( key, value )

	if value == nil then
		return KSKeychainService.delete( key )
	elseif value == kUnchanged then
		return Observable.empty()
	else
		return KSKeychainService.write( key, value )
	end

end

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

function WFOAuth2SessionImp:storeTokens( accessToken, refreshToken, optDeviceToken )

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

	logger:infof( "Store tokens for session ID %s", self._sessionId )

	self._accessAndRefreshTokens:onNext( accessToken, refreshToken, optDeviceToken )

	local userInfo = KSJsonEncoder.encodeOzUploadJson {
		access_token = accessToken,
		refresh_token = refreshToken,
	}

	return updateKeychain( self._sessionId, userInfo )

		:merge( updateKeychain( self._sessionId .. '.device_token', optDeviceToken ) )

end

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

function WFOAuth2SessionImp:fetchTokens()

	check( 'WFOAuth2SessionImp' )

	return KSKeychainService.read( self._sessionId )

		:map( function( rawJson )

			local decodedJson = KSJsonDecoder.decodeWebSafeJson_stripNull( rawJson )

			if type( decodedJson ) == 'table' then
				logger:infof( "Found existing access and refresh token in keychain" )
				return decodedJson.access_token, decodedJson.refresh_token
			end

			return nil

		end )

		:flatMap( function( accessToken, refreshToken )

			return KSKeychainService.read( self._sessionId .. '.device_token' )
				:map( function( deviceToken )

					return accessToken, refreshToken, deviceToken

				end )

		end )

end

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

local function statusCodeFromMaybeHeaders( maybeHeaders )

	if type( maybeHeaders ) == 'table' then

		local statusCode = maybeHeaders.status_code

		if type( statusCode ) == 'string' then
			return statusCode
		end

	end

end

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

local function refreshFromDeviceToken( self, deviceToken )

	return Observable.throw( "refreshFromDeviceToken not yet implemented" )

end

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

local function requestNewTokensFromIMS( self, refreshRequest, accessToken, refreshToken, optDeviceToken )

	return Observable.pcall( function()
	
		-- authHeader is WWW-Authenticate header containing imsBaseUrl from Oz
		local authHeader = refreshRequest.authHeader
		
		local authUrl
		
 		if authHeader then
 			authUrl = authHeader:match( [[auth_url="([^"]+)"]] )

	 		if authUrl then
	 			authUrl = authUrl:match( [[(.*)/authorize]] )
	 		end
 		end

		local imsBaseUrlObs

 		if authUrl then
			imsBaseUrlObs = Observable.just( authUrl )
		else
			-- If there's no authUrl, we use WFOzClient.getImsAuthUrl
			
			local overrides = {}
			
			if self._serverBase then
				overrides.baseServerUrl = self._serverBase
			end
	
			imsBaseUrlObs = WFOzClient.getImsAuthUrl( overrides )
				:take( 1 )
				:map( function( _, imsBaseUrl )
					if not imsBaseUrl then
						error "requestNewTokens: imsBaseUrl from getImsAuthUrl is nil"
					end
					
					return imsBaseUrl
				end )
		end
		
		return imsBaseUrlObs
			:map( function( imsBaseUrl )
			
				local refreshUrl = WFURI.resolve( imsBaseUrl, "/ims/token" )
				return refreshUrl
				
			end )
			
			:flatMap( function( refreshUrl )
			
				local clientId, clientSecret = WFImsAppIdentity.getClientIdAndSecret()
		
				assert( clientId ~= nil, "requestNewTokens: clientId is nil" )
				assert( clientSecret ~= nil, "requestNewTokens:: clientSecret is nil" )
		
				local postBody = string.format(
						"grant_type=refresh_token&refresh_token=%s&client_id=%s&client_secret=%s",
						refreshToken, clientId, clientSecret )
		
				local refreshPostArgs = {
					requestMethod = 'POST',
					requestHeaders = {
						{ header = 'Content-Type', value = 'application/x-www-form-urlencoded' },
						{ header = 'X-IMS-ClientID', value = clientId },
					},
				}
		
				return WFRxRetryObservable {
		
					maxAttempts = 3,
					retryDelay = 2,
		
					factory = function( count )
		
						logger:debugf( "refresh attempt #%s using url = %s\n\npostBody = %s\n\n"
									   .. "Expired accessToken = %s\n"
									   .. "refreshToken = %s\n\n",
									   count, refreshUrl, postBody, accessToken, refreshToken )
		
						return self._httpStack.put( refreshUrl, postBody, refreshPostArgs )
		
							:map( KSJsonDecoder.decodeWebSafeJson_stripNull )
		
							-- Extract the tokens we need from IMS response.
		
							:map( function( userInfo )
		
								logger:debugf( "refreshRequest returned %s", userInfo )
		
								if not userInfo.access_token then
		
									logger:errorf( "refreshRequest missing access_token :: returned %s",
												   userInfo )
		
									error "missing access_token"
		
								end
		
								return userInfo.access_token,
									   userInfo.refresh_token or refreshToken,
									   optDeviceToken
		
							end )
		
							-- If we failed with a permissions-style error, see if we can
							-- fall back to device token.
		
							:catch( function( data, headers )
		
								local statusCode = statusCodeFromMaybeHeaders( headers )
		
								if statusCode
									and statusCode >= '400'
									and statusCode <= '499'
									and optDeviceToken
								then
		
									-- We have a device token stored. If so, use it to get a
									-- new access/refresh token pair. If not, or if this grant fails,
									-- force-logout as usual.
		
									return refreshFromDeviceToken( optDeviceToken )
		
								else
		
									-- If any other failure case, just rethrow the error.
		
									return Observable.throw( data, headers )
		
								end
		
							end )
		
							-- Store the tokens in keychain.
		
							:flatMap( function( accessToken, refreshToken, optDeviceToken )
		
								return self:storeTokens( accessToken, refreshToken, optDeviceToken )
									:concat( Observable.just( accessToken, refreshToken, optDeviceToken ) )
		
							end )
		
							-- Log results.
		
							:tap(
								function( accessToken, refreshToken, optDeviceToken )
									logger:infof( "Successfully refreshed tokens" )
								end,
								function( data, headers )
									logger:errorf( "Failed to refresh tokens %s %s", data, headers )
								end )
		
					end,
	
				}
				
			end )
			
	end )
	
end

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

local function maybeRequestNewTokensFromExternalProvider( self, refreshRequest, accessToken, refreshToken, optDeviceToken )

	if self._nativeTokenFreshProviderModule then
		
		return WFRxNativeObservable( self._nativeTokenFreshProviderModule.requestNewTokens, accessToken, refreshToken, optDeviceToken )
			
			:flatMap( function( tokens )

				assert( type( tokens ) == 'table', 
					"Native provider needs to provide a table consisting the refreshed tokens" )
					
				local accessToken = tokens.accessToken
				local refreshToken = tokens.refreshToken
				local optDeviceToken = tokens.deviceToken

				local WFPlatformInfo = require 'WFPlatformInfo'
                		-- This check is being made for the reason that deviceToken can be empty for few locales like Chinese
				if WFPlatformInfo.isAndroid and optDeviceToken and optDeviceToken == '' then
					optDeviceToken = nil
				end
				return self:storeTokens( accessToken, refreshToken, optDeviceToken )
					:concat( Observable.just( accessToken, refreshToken, optDeviceToken ) )

			end )

			-- Log results.

			:tap(
				function( accessToken, refreshToken, optDeviceToken )
					logger:infof( "Successfully refreshed tokens via External Provider" )
				end,
				function( err )
					logger:errorf( "Failed to refresh tokens %s via External Provider", err )
				end )
				
	end
	
	return nil
	
end

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

requestNewTokens = function( self, refreshRequest, accessToken, refreshToken, optDeviceToken )

	-- Post a magic value to the token stream. This will cause all future requests
	-- to stall until we can post a new access token. This will also cause any
	-- further refresh token requests that originated from the now-expired access
	-- token to get discarded since we've already got a refresh request in flight.

	logger:debugf( "requestNewTokens starting" )

	if self._requestNewTokensSubscribeHandle then
		logger:warn "requestNewTokens: a previous subscribe handle still exists"
	end

	self._accessAndRefreshTokens:onNext( 'refresh', refreshToken, optDeviceToken )

	local requestNewTokensFlow = maybeRequestNewTokensFromExternalProvider( self, refreshRequest, 
									accessToken, refreshToken, optDeviceToken )
	
	if not requestNewTokensFlow then
		requestNewTokensFlow = requestNewTokensFromIMS( self, refreshRequest, accessToken, 
									refreshToken, optDeviceToken )
	end

	requestNewTokensFlow
		:take( 1 )

	self._requestNewTokensSubscribeHandle = requestNewTokensFlow:subscribe(

		function( accessToken, refreshToken, optDeviceToken )

			-- Congratulations, you have successfully navigated the
			-- OAuth2 minefield. Save these tokens and hope that you
			-- are as fortunate next time around.

			-- Posting to the access token stream will unblock any
			-- requests that were stalled while we waited for new tokens.

			logger:debugf( "requestNewTokens finished successfully" )

			self._requestNewTokensSubscribeHandle = nil

			self._accessAndRefreshTokens:onNext( accessToken, refreshToken, optDeviceToken )

		end,

		function( data, headers )

			-- No dice. Giving up and signaling failure for all
			-- future calls.

			logger:errorf( "requestNewTokens failed\n\ndata = %s\n\nheaders = %s\n\n",
						   data, headers )

			self._requestNewTokensSubscribeHandle = nil

			self._accessAndRefreshTokens:onNext( 'error', tostring( data ) )

			local statusCode = statusCodeFromMaybeHeaders( headers )

			if statusCode and statusCode >= '400' and statusCode <= '499' then

				KSKeychainService.delete( self._sessionId )
				KSKeychainService.delete( self._sessionId .. '.device_token' )

			end

		end )

end

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

local function maybeDeferredCloseData( maybeData )

	if KSData.isData( maybeData ) then
		WFRequestDispatcher.deferredClose( maybeData )
	end

end

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

local function acceptLanguagePreferencesString( self )

	if self._languagePrefString then
		return self._languagePrefString
	end

	local localeId = WFLocalizationUtils.getLocaleId() -- e.g. "en-US"
	local langCode = WFLocalizationUtils.getLanguageCode() -- e.g. "en"

	-- This fallback shouldn't be necessary, but better this
	-- than sending some badly-formatted string to Oz.

	-- Purposely don't cache this bad answer, maybe it will work next time.

	if not localeId then
		logger:error( "No locale ID available" )
		return langCode or ''
	end

	-- e.g. "Accept-Language: en-US, en;q=0.8"
	-- Indicates that the region-specific language identifier is highest preference,
	-- (default q=1), but fallback to any other regional variant of the same language.
	-- q=0.8 is a totally arbitrary value less than 1. Doesn't really matter the value we
	-- choose if there is not a third or fourth preference.

	self._languagePrefString = string.format( '%s, %s;q=0.8', localeId, langCode )

	return self._languagePrefString

end

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

local function getPut_imp( self, url, args, getPutInnerFunc )

	logger:infof( "getPut_imp for URL %s", url )

	local function debugOutput( data, headers )

		local statusCode = statusCodeFromMaybeHeaders( headers )

		if statusCode and statusCode < '400' then
			logger:debugf( "normal response on URL %s\n\nheaders = %s", url, headers )
		elseif data ~= 'cancelled' and data ~= 'requestInProgress' then
			logger:warnf( "error on URL %s\n\ndata = %s\n\nheaders = %s", url, data, headers )
		end

	end

	local _accessToken, _refreshToken, _maybeDeviceToken

	return WFRxRetryObservable {

		maxAttempts = 2,
		retryDelay = .5, -- give time for token refresh to at least start

		factory = function( count )

			logger:debugf( "getPut_imp factory function starting for URL %s", url )

			return self._validAccessAndRefreshTokens

				:timeout( 10,
						  count > 1 and "Unable to refresh tokens" or "No access token available",
						  WFRxScheduler.sharedScheduler() )

				:take( 1 )

				:flatMapLatest( function( accessToken, refreshToken, maybeDeviceToken )

					logger:debugf( "getPut_imp factory function got tokens, ready to start URL %s", url )

					assert( accessToken, "missing access token" )
					assert( refreshToken, "missing refresh token" )

					-- Remember the current token set in case we have to trigger
					-- a refresh request.

					_accessToken = accessToken
					_refreshToken = refreshToken
					_maybeDeviceToken = maybeDeviceToken

					-- TODO [scouten 2015-07-19]: Restore progress reporting.

					-- Given that we might need to retry, make sure we
					-- don't dispose the data until the show's completely over.

					args = args or {}
					args.retainDataAfterRequest = true

					-- Strip any previous Authorization and x-token-ttl headers.

					local headers = args.requestHeaders or {}
					args.requestHeaders = headers

					local hasAcceptLanguageHeader = false

					for i = #headers, 1, -1 do

						if headers[ i ].header == 'Accept-Language' then
							hasAcceptLanguageHeader = true
						end

						if headers[ i ].header == 'Authorization'
							or headers[ i ].header == 'x-token-ttl'
						then
							table.remove( headers, i )
						end

					end

					-- Create the request based on current access token.
					if not args.withoutAdobeAuthorization then
						headers[ #headers + 1 ] =
							{ header = 'Authorization', value = 'Bearer ' .. accessToken }
					end

					-- Add an Accept-Language header.

					if not hasAcceptLanguageHeader then
						headers[ #headers + 1 ] =
							{ header = 'Accept-Language', value = acceptLanguagePreferencesString( self ) }
					end

					-- Call the inner get/put function.

					logger:debugf( "sending request to getPutInnerFunc with args = %s", args )

					return getPutInnerFunc( args )
						:tap( debugOutput, debugOutput )

				end )

				-- Make sure we don't leak any data from this response.

				:tap( maybeDeferredCloseData, maybeDeferredCloseData )

				-- In the possibility caller chose to map HTTP error codes to success,
				-- convert OAuth2-related status codes back to errors. We'll catch those
				-- in the retry logic.

				:flatMapLatest( function( data, headers, ... )

					local statusCode = statusCodeFromMaybeHeaders( headers )

					if statusCode == '401' then
						return Observable.throw( data, headers, ... )
					end

					-- TODO [scouten 2015-07-19]: Consider restoring 403 error handling
					-- here? 403 + Oz error 4999 means client kill switch has been
					-- invoked by Oz. I'd prefer that move up to sync agent code.

					return Observable.just( data, headers, ... )

				end )

		end,

		retryFilter = function( data, headers )

			logger:debugf( "getPut_imp retryFilter examining error response to url %s \n\ndata %s headers %s",
						   url, data, headers )

			-- We only retry 401 errors at this point.

			-- 401 (not authorized) mean swe have an invalid access token.
			-- This means we should suspend any further requests and use the
			-- refresh token to try again.

			local statusCode = statusCodeFromMaybeHeaders( headers )

			if statusCode == '401' then
			
				logger:infof( "Detected invalid access token %s", headers )

				local authHeader = headers[ 'Www-Authenticate' ] or headers[ 'WWW-Authenticate' ]

				-- Note that there is a possible race condition in that several
				-- HTTP requests might have originated with the same now-invalid
				-- access token. By design, _refreshRequests is the fire hose.
				-- We de-duplicate the requests in the construction of
				-- _refreshNeeded (see this class's __init method). Here, we pass
				-- the access token that was used to initiate this HTTP request.
				-- If it's still valid, then we initiate the refresh; if not,
				-- we ignore this refresh request because another request has already
				-- failed and triggered the appropriate refresh.

				self._refreshRequests:onNext {
					accessToken = _accessToken,
					refreshToken = _refreshToken,
					deviceToken = _maybeDeviceToken,
					authHeader = authHeader
				}

				return true

			end

			-- All other errors are not retried.

			return false

		end,

	}

end

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

function WFOAuth2SessionImp:get( url, optArgs, optSequencing )

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

	-- TODO [scouten 2015-07-19]: Restore sequencing.

	return getPut_imp( self, url, optArgs,
		function( newArgs )

			return self._httpStack.get( url, newArgs, optSequencing )

		end )

end

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

function WFOAuth2SessionImp:put( url, data, optArgs, optSequencing )

	check( 'WFOAuth2SessionImp', '+string', 'KSData|string|number', '?table' )

	local progressReporter
	local progressStatusStream

	if optArgs and optArgs.includeProgress then
	
		progressReporter = WFProgressReporter( string.format( "put::%s", url ) )
		progressStatusStream = progressReporter:status()
		
	end

	return getPut_imp( self, url, optArgs,
	
		function( newArgs )

			if progressStatusStream then
			
				local packedPutRequest = self._httpStack.put( url, data, newArgs, progressReporter )
				
					:map( function( responseData, headers )
						return { responseData, headers }
					end )
					
					:finally( function()
						progressReporter:done()
					end )
		
				return Observable.combineLatest( packedPutRequest, progressStatusStream )
					
					:map( function( responseDataAndHeaders, ... )
					
						local responseData, headers = unpack( responseDataAndHeaders )
					
						return responseData, headers, ...
						
					end )
					
			else

				return self._httpStack.put( url, data, newArgs )

			end

		end )

		:finally( function()
			if KSData.isData( data ) then
				data:close()
			end
		end )

end

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

function WFOAuth2SessionImp:hasValidLogin()

	check( 'WFOAuth2SessionImp' )

	return self._hasValidLogin

end

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

local function deleteAuthKeys( self )

	KSKeychainService.delete( self._sessionId )
	KSKeychainService.delete( self._sessionId .. '.device_token' )

	self._accessAndRefreshTokens:onNext( 'error', "logged out" )

end

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

function WFOAuth2SessionImp:simulateForcedLogout()

	check( 'WFOAuth2SessionImp' )

	logger:error( "Simulate forced logout" )

	deleteAuthKeys( self )

	return Observable.empty()

end

--------------------------------------------------------------------------------
	
local function getImsLogoutUrl( imsBaseUrl, accessToken, clientId )
	
	-- FIXME [jandrew 9/2/2015]: replace string.format with WFURI
	
	return string.format( "%s/logout?access_token=%s&client_id=%s&redirect_uri=https://fake.domain.adobe.com",
				   imsBaseUrl,
				   accessToken,
				   clientId )

end

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

local function logout( self )

	--	From https://wiki.corp.adobe.com/display/ims/IMS+API+-+logout :
	--	Front-Channel logout
	--
	--	Request:
	--	  GET /ims/logout/v1?access_token=ACCESS_TOKEN&client_id=CLIENT_ID HTTP/1.1
	--	  Host: ims-na1-stg1.adobelogin.com
	--
	--	Response:
	--	  HTTP/1.1 200
	--	  Cache-Control: no-store

	return self._accessAndRefreshTokens
	
		:take( 1 )
	
		:flatMapLatest( function( accessToken )
		
			deleteAuthKeys( self )
		
			if not accessToken or accessToken == 'refresh' then
				logger:debugf( "User is not valid." )
				return Observable.empty()
			end
		
			local overrides = {}
			
			if self._serverBase then
				overrides.baseServerUrl = self._serverBase
			end
		
			return WFOzClient.getImsAuthUrl( overrides )

				:selectMany( function( imsAuthUrl, imsBaseUrl )
		
					local clientId = WFImsAppIdentity.getClientIdAndSecret()
		
					local logoutUrl = getImsLogoutUrl( imsBaseUrl, accessToken, clientId )
		
					local logoutArgs = {
						requestMethod = 'GET',
					}
			
					return self._httpStack.put( logoutUrl, nil, logoutArgs )
		
				end )
		
		end )

		:map( function( data, headers )
				
			data = tostring( data )
				
			local responseStatusCode = headers and headers.status_code

			if responseStatusCode ~= "302" then
				return false, data
			else
				return true
			end
		
		end )
	
end

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

function WFOAuth2SessionImp:logout()

	check( 'WFOAuth2SessionImp' )
		
	return self._hasValidLogin
		:take( 1 )
		:map( function( isValid )

			if isValid then
		
				return logout( self )

			else

				return Observable.empty()

			end
		
		end )
		:switch()

end

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

-- @mark miscellaneous

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

function WFOAuth2SessionImp:__tostring()

	check( 'WFOAuth2SessionImp' )

	return string.format( "WFOAuth2SessionImp( %s )", self._sessionId )

end

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

function WFOAuth2SessionImp:DEBUG_replaceHttpStack( httpStack )

	check( 'WFOAuth2SessionImp', 'table' )

	self._httpStack = httpStack

end

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

function WFOAuth2SessionImp:DEBUG_dumpCoverage( path )

	check( 'WFOAuth2SessionImp', '+string' )

	require 'SetupCoverage'.dumpCoverage( path )

	return Observable.empty()

end

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

return Rx.Class 'WFOAuth2SessionImp'( WFOAuth2SessionImp )
