/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.service.fxa.manager

import android.content.Context
import androidx.annotation.GuardedBy
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext
import mozilla.appservices.syncmanager.DeviceSettings
import mozilla.components.concept.sync.AuthFlowError
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.AuthFlowUrl
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.AccountEventsObserver
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.concept.sync.InFlightMigrationState
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
import mozilla.components.concept.sync.ServiceResult
import mozilla.components.service.fxa.AccountManagerException
import mozilla.components.service.fxa.AccountStorage
import mozilla.components.service.fxa.FxaDeviceSettingsCache
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.SecureAbove22AccountStorage
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.SharedPrefAccountStorage
import mozilla.components.service.fxa.SyncAuthInfoCache
import mozilla.components.service.fxa.SyncConfig
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.asSyncAuthInfo
import mozilla.components.service.fxa.intoSyncType
import mozilla.components.service.fxa.sharing.AccountSharing
import mozilla.components.service.fxa.sharing.ShareableAccount
import mozilla.components.service.fxa.sync.SyncManager
import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.service.fxa.sync.SyncStatusObserver
import mozilla.components.service.fxa.sync.WorkManagerSyncManager
import mozilla.components.service.fxa.sync.clearSyncState
import mozilla.components.concept.base.crash.CrashReporting
import mozilla.components.service.fxa.AccountOnDisk
import mozilla.components.service.fxa.Result
import mozilla.components.service.fxa.StorageWrapper
import mozilla.components.service.fxa.asAuthFlowUrl
import mozilla.components.service.fxa.withRetries
import mozilla.components.service.fxa.withServiceRetries
import mozilla.components.support.base.utils.NamedThreadFactory
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.base.observer.Observable
import mozilla.components.support.base.observer.ObserverRegistry
import org.json.JSONObject
import java.io.Closeable
import java.lang.Exception
import java.lang.IllegalArgumentException
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors
import kotlin.coroutines.CoroutineContext

// Necessary to fetch a profile.
const val SCOPE_PROFILE = "profile"
// Necessary to obtain sync keys.
const val SCOPE_SYNC = "https://identity.mozilla.com/apps/oldsync"
// Necessary to obtain a sessionToken, which gives full access to the account.
const val SCOPE_SESSION = "https://identity.mozilla.com/tokens/session"

// If we see more than AUTH_CHECK_CIRCUIT_BREAKER_COUNT checks, and each is less than
// AUTH_CHECK_CIRCUIT_BREAKER_RESET_MS since the last check, then we'll trigger a "circuit breaker".
const val AUTH_CHECK_CIRCUIT_BREAKER_RESET_MS = 1000L * 10
const val AUTH_CHECK_CIRCUIT_BREAKER_COUNT = 10
// This logic is in place to protect ourselves from endless auth recovery loops, while at the same
// time allowing for a few 401s to hit the state machine in a quick succession.
// For example, initializing the account state machine & syncing after letting our access tokens expire
// due to long period of inactivity will trigger a few 401s, and that shouldn't be a cause for concern.

const val MAX_NETWORK_RETRIES = 3

/**
 * Describes a result of running [FxaAccountManager.migrateFromAccount].
 */
enum class MigrationResult {
    /**
     * Sign-in failed due to an intermittent problem (such as a network failure). A retry attempt will
     * be performed automatically during account manager initialization, or as a side-effect of certain
     * user actions (e.g. triggering a sync).
     *
     * Applications may treat this account as "authenticated" after seeing this result.
     */
    WillRetry,

    /**
     * Sign-in succeeded with no issues.
     * Applications may treat this account as "authenticated" after seeing this result.
     */
    Success,

    /**
     * Sign-in failed due to non-recoverable issues.
     */
    Failure
}

/**
 * An account manager which encapsulates various internal details of an account lifecycle and provides
 * an observer interface along with a public API for interacting with an account.
 * The internal state machine abstracts over state space as exposed by the fxaclient library, not
 * the internal states experienced by lower-level representation of a Firefox Account; those are opaque to us.
 *
 * Class is 'open' to facilitate testing.
 *
 * @param context A [Context] instance that's used for internal messaging and interacting with local storage.
 * @param serverConfig A [ServerConfig] used for account initialization.
 * @param deviceConfig A description of the current device (name, type, capabilities).
 * @param syncConfig Optional, initial sync behaviour configuration. Sync will be disabled if this is `null`.
 * @param applicationScopes A set of scopes which will be requested during account authentication.
 */
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
open class FxaAccountManager(
    private val context: Context,
    @VisibleForTesting val serverConfig: ServerConfig,
    private val deviceConfig: DeviceConfig,
    private val syncConfig: SyncConfig?,
    private val applicationScopes: Set<String> = emptySet(),
    private val crashReporter: CrashReporting? = null,
    // We want a single-threaded execution model for our account-related "actions" (state machine side-effects).
    // That is, we want to ensure a sequential execution flow, but on a background thread.
    private val coroutineContext: CoroutineContext = Executors.newSingleThreadExecutor(
        NamedThreadFactory("FxaAccountManager")
    ).asCoroutineDispatcher() + SupervisorJob()
) : Closeable, Observable<AccountObserver> by ObserverRegistry() {
    private val logger = Logger("FirefoxAccountStateMachine")

    /**
     * Observer interface which lets its consumers respond to authentication requests.
     */
    private interface OAuthObserver {
        /**
         * Account manager is requesting for an OAUTH flow to begin.
         * @param authFlowUrl Starting point for the OAUTH flow.
         */
        fun onBeginOAuthFlow(authFlowUrl: AuthFlowUrl)

        /**
         * Account manager encountered an error during authentication.
         */
        fun onError() {}
    }

    @Volatile
    private var latestAuthState: String? = null

    private val oauthObservers = object : Observable<OAuthObserver> by ObserverRegistry() {}

    init {
        GlobalAccountManager.setInstance(this)
    }

    private val accountOnDisk by lazy { getStorageWrapper().account() }
    private val account by lazy { accountOnDisk.account() }

    // Note on threading: we use a single-threaded executor, so there's no concurrent access possible.
    // However, that executor doesn't guarantee that it'll always use the same thread, and so vars
    // are marked as volatile for across-thread visibility. Similarly, event queue uses a concurrent
    // list, although that's probably an overkill.
    @Volatile private var profile: Profile? = null

    // We'd like to persist this state, so that we can short-circuit transition to AuthenticationProblem on
    // initialization, instead of triggering the full state machine knowing in advance we'll hit auth problems.
    // See https://github.com/mozilla-mobile/android-components/issues/5102
    @Volatile private var state: State = State.Idle(AccountState.NotAuthenticated)
    private val eventQueue = ConcurrentLinkedQueue<Event>()

    @VisibleForTesting
    val accountEventObserverRegistry = ObserverRegistry<AccountEventsObserver>()
    private val syncStatusObserverRegistry = ObserverRegistry<SyncStatusObserver>()

    // We always obtain a "profile" scope, as that's assumed to be needed for any application integration.
    // We obtain a sync scope only if this was requested by the application via SyncConfig.
    // Additionally, we obtain any scopes that the application requested explicitly.
    private val scopes: Set<String>
        get() = if (syncConfig != null) {
            setOf(SCOPE_PROFILE, SCOPE_SYNC)
        } else {
            setOf(SCOPE_PROFILE)
        }.plus(applicationScopes)

    // Internal backing field for the syncManager. This will be `null` if passed in SyncConfig (either
    // via the constructor, or via [setSyncConfig]) is also `null` - that is, sync will be disabled.
    // Note that trying to perform a sync while account isn't authenticated will not succeed.
    @GuardedBy("this")
    private var syncManager: SyncManager? = null

    init {
        syncConfig?.let {
            // Initialize sync manager with the passed-in config.
            if (syncConfig.supportedEngines.isEmpty()) {
                throw IllegalArgumentException("Set of supported engines can't be empty")
            }

            syncManager = createSyncManager(syncConfig).also {
                // Observe account state changes.
                this.register(AccountsToSyncIntegration(it))

                // Observe sync status changes.
                it.registerSyncStatusObserver(SyncToAccountsIntegration(this))
            }
        }

        if (syncManager == null) {
            logger.info("Sync is disabled")
        } else {
            logger.info("Sync is enabled")
        }
    }

    /**
     * Queries trusted FxA Auth providers available on the device, returning a list of [ShareableAccount]
     * in an order of preference. Any of the returned [ShareableAccount] may be used with
     * [migrateFromAccount] to sign-in into an FxA account without any required user input.
     */
    fun shareableAccounts(context: Context): List<ShareableAccount> {
        return AccountSharing.queryShareableAccounts(context)
    }

    /**
     * Uses a provided [fromAccount] to sign-in into a corresponding FxA account without any required
     * user input. Once sign-in completes, any registered [AccountObserver.onAuthenticated] listeners
     * will be notified and [authenticatedAccount] will refer to the new account.
     * This may fail in case of network errors, or if provided credentials are not valid.
     * @param reuseSessionToken Whether or not to reuse existing session token (which is part of the [ShareableAccount].
     * @return A deferred boolean flag indicating success (if true) of the sign-in operation.
     */
    suspend fun migrateFromAccount(
        fromAccount: ShareableAccount,
        reuseSessionToken: Boolean = false
    ): MigrationResult = withContext(coroutineContext) {
        processQueue(Event.Account.MigrateFromAccount(fromAccount, reuseSessionToken))

        when (val s = state) {
            is State.Idle -> when (s.accountState) {
                AccountState.Authenticated -> MigrationResult.Success
                AccountState.IncompleteMigration -> MigrationResult.WillRetry
                else -> MigrationResult.Failure
            }
            else -> MigrationResult.Failure
        }
    }

    /**
     * @return A list of currently supported [SyncEngine]s. `null` if sync isn't configured.
     */
    fun supportedSyncEngines(): Set<SyncEngine>? {
        // Notes on why this exists:
        // Parts of the system that make up an "fxa + sync" experience need to know which engines
        // are supported by an application. For example, FxA web content UI may present a "choose what
        // to sync" dialog during account sign-up, and application needs to be able to configure that
        // dialog. A list of supported engines comes to us from the application via passed-in SyncConfig.
        // Naturally, we could let the application configure any other part of the system that needs
        // to have access to supported engines. From the implementor's point of view, this is an extra
        // hurdle - instead of configuring only the account manager, they need to configure additional
        // classes. Additionally, we currently allow updating sync config "in-flight", not just at
        // the time of initialization. Providing an API for accessing currently configured engines
        // makes re-configuring SyncConfig less error-prone, as only one class needs to be told of the
        // new config.
        // Merits of allowing applications to re-configure SyncConfig after initialization are under
        // question, however: currently, we do not use that capability.
        return syncConfig?.supportedEngines
    }

    /**
     * Request an immediate synchronization, as configured according to [syncConfig].
     *
     * @param reason A [SyncReason] indicating why this sync is being requested.
     * @param debounce Boolean flag indicating if this sync may be debounced (in case another sync executed recently).
     */
    suspend fun syncNow(
        reason: SyncReason,
        debounce: Boolean = false
    ) = withContext(coroutineContext) {
        when (val s = state) {
            // Can't sync while we're still doing stuff.
            is State.Active -> Unit
            is State.Idle -> when (s.accountState) {
                // If we're in an incomplete migration state, try to complete it.
                // This is one of our trigger points for retrying - when a user asks us to sync.
                // Another trigger point is the startup flow of the account manager itself.
                AccountState.IncompleteMigration -> {
                    processQueue(Event.Account.RetryMigration)
                }
                // All good, request a sync.
                AccountState.Authenticated -> {
                    // Make sure auth cache is populated before we try to sync.
                    maybeUpdateSyncAuthInfoCache()

                    // Access to syncManager is guarded by `this`.
                    synchronized(this@FxaAccountManager) {
                        checkNotNull(syncManager == null) {
                            "Sync is not configured. Construct this class with a 'syncConfig' or use 'setSyncConfig'"
                        }
                        syncManager?.now(reason, debounce)
                    }
                }
                else -> logger.info("Ignoring syncNow request, not in the right state: $s")
            }
        }
    }

    /**
     * Indicates if sync is currently running.
     */
    fun isSyncActive() = syncManager?.isSyncActive() ?: false

    /**
     * Call this after registering your observers, and before interacting with this class.
     */
    suspend fun start() = withContext(coroutineContext) {
        processQueue(Event.Account.Start)
    }

    /**
     * Main point for interaction with an [OAuthAccount] instance.
     * @return [OAuthAccount] if we're in an authenticated state, null otherwise. Returned [OAuthAccount]
     * may need to be re-authenticated; consumers are expected to check [accountNeedsReauth].
     */
    fun authenticatedAccount(): OAuthAccount? = when (val s = state) {
        is State.Idle -> when (s.accountState) {
            AccountState.Authenticated,
            AccountState.IncompleteMigration,
            AccountState.AuthenticationProblem -> account
            else -> null
        }
        else -> null
    }

    /**
     * Indicates if account needs to be re-authenticated via [beginAuthentication].
     * Most common reason for an account to need re-authentication is a password change.
     *
     * TODO this may return a false-positive, if we're currently going through a recovery flow.
     * Prefer to be notified of auth problems via [AccountObserver], which is reliable.
     *
     * @return A boolean flag indicating if account needs to be re-authenticated.
     */
    fun accountNeedsReauth() = when (val s = state) {
        is State.Idle -> when (s.accountState) {
            AccountState.AuthenticationProblem -> true
            else -> false
        }
        else -> false
    }

    /**
     * Returns a [Profile] for an account, attempting to fetch it if necessary.
     * @return [Profile] if one is available and account is an authenticated state.
     */
    fun accountProfile(): Profile? = when (val s = state) {
        is State.Idle -> when (s.accountState) {
            AccountState.Authenticated,
            AccountState.AuthenticationProblem -> profile
            else -> null
        }
        else -> null
    }

    /**
     * Fetches an up-to-date [Profile] if account is in an authenticated state.
     */
    suspend fun fetchProfile(): Profile? {
        return refreshProfile(true)
    }

    private suspend fun refreshProfile(ignoreCache: Boolean): Profile? {
        return authenticatedAccount()?.getProfile(ignoreCache = ignoreCache)?.let { newProfile ->
            profile = newProfile
            notifyObservers {
                onProfileUpdated(newProfile)
            }
            profile
        }
    }

    /**
     * Begins an authentication process. Should be finalized by calling [finishAuthentication] once
     * user successfully goes through the authentication at the returned url.
     * @param pairingUrl Optional pairing URL in case a pairing flow is being initiated.
     * @return An authentication url which is to be presented to the user.
     */
    suspend fun beginAuthentication(pairingUrl: String? = null): String? {
        // It's possible that at this point authentication is considered to be "in-progress".
        // For example, if user started authentication flow, but cancelled it (closing a custom tab)
        // without finishing.
        // In a clean scenario (no prior auth attempts), this event will be ignored by the state machine.
        processQueue(Event.Progress.CancelAuth)

        val event = if (pairingUrl != null) {
            Event.Account.BeginPairingFlow(pairingUrl)
        } else {
            Event.Account.BeginEmailFlow
        }

        // 'deferredAuthUrl' will be set as the state machine reacts to the 'event'.
        var deferredAuthUrl: String? = null
        oauthObservers.register(object : OAuthObserver {
            override fun onBeginOAuthFlow(authFlowUrl: AuthFlowUrl) {
                deferredAuthUrl = authFlowUrl.url
            }

            override fun onError() {
                // No-op for now.
                logger.warn("Got an error during 'beginAuthentication'")
            }
        })
        processQueue(event)
        oauthObservers.unregisterObservers()
        return deferredAuthUrl
    }

    /**
     * Finalize authentication that was started via [beginAuthentication].
     *
     * If authentication wasn't started via this manager we won't accept this authentication attempt,
     * returning `false`. This may happen if [WebChannelFeature] is enabled, and user is manually
     * logging into accounts.firefox.com in a regular tab.
     *
     * Guiding principle behind this is that logging into accounts.firefox.com should not affect
     * logged-in state of the browser itself, even though the two may have an established communication
     * channel via [WebChannelFeature].
     *
     * @return A deferred boolean flag indicating if authentication state was accepted.
     */
    suspend fun finishAuthentication(authData: FxaAuthData) = withContext(coroutineContext) {
        when {
            latestAuthState == null -> {
                logger.warn("Trying to finish authentication that was never started.")
                false
            }
            authData.state != latestAuthState -> {
                logger.warn("Trying to finish authentication for an invalid auth state; ignoring.")
                false
            }
            authData.state == latestAuthState -> {
                processQueue(Event.Progress.AuthData(authData))
                true
            }
            else -> throw IllegalStateException("Unexpected finishAuthentication state")
        }
    }

    /**
     * Logout of the account, if currently logged-in.
     */
    suspend fun logout() = withContext(coroutineContext) { processQueue(Event.Account.Logout) }

    /**
     * Register a [AccountEventsObserver] to monitor events relevant to an account/device.
     */
    fun registerForAccountEvents(observer: AccountEventsObserver, owner: LifecycleOwner, autoPause: Boolean) {
        accountEventObserverRegistry.register(observer, owner, autoPause)
    }

    /**
     * Register a [SyncStatusObserver] to monitor sync activity performed by this manager.
     */
    fun registerForSyncEvents(observer: SyncStatusObserver, owner: LifecycleOwner, autoPause: Boolean) {
        syncStatusObserverRegistry.register(observer, owner, autoPause)
    }

    override fun close() {
        GlobalAccountManager.close()
        coroutineContext.cancel()
        account.close()
    }

    internal suspend fun encounteredAuthError(
        operation: String,
        errorCountWithinTheTimeWindow: Int = 1
    ) = withContext(coroutineContext) {
        processQueue(
            Event.Account.AuthenticationError(operation, errorCountWithinTheTimeWindow)
        )
    }

    /**
     * Pumps the state machine until all events are processed and their side-effects resolve.
     */
    private suspend fun processQueue(event: Event) = withContext(coroutineContext) {
        eventQueue.add(event)
        do {
            val toProcess: Event = eventQueue.poll()!!
            val transitionInto = state.next(toProcess)

            if (transitionInto == null) {
                logger.warn("Got invalid event '$toProcess' for state $state.")
                continue
            }

            logger.info("Processing event '$toProcess' for state $state. Next state is $transitionInto")

            state = transitionInto

            stateActions(state, toProcess)?.let { successiveEvent ->
                logger.info("Ran '$toProcess' side-effects for state $state, got successive event $successiveEvent")
                eventQueue.add(successiveEvent)
            }
        } while (!eventQueue.isEmpty())
    }

    private suspend fun accountStateSideEffects(forState: State.Idle, via: Event): Unit = when (forState.accountState) {
        AccountState.NotAuthenticated -> when (via) {
            Event.Progress.LoggedOut -> {
                notifyObservers { onLoggedOut() }
            }
            Event.Progress.FailedToBeginAuth -> {
                notifyObservers { onFlowError(AuthFlowError.FailedToBeginAuth) }
            }
            Event.Progress.FailedToCompleteAuth -> {
                notifyObservers { onFlowError(AuthFlowError.FailedToCompleteAuth) }
            }
            is Event.Progress.FailedToCompleteMigration -> {
                notifyObservers { onFlowError(AuthFlowError.FailedToMigrate) }
            }
            else -> Unit
        }
        AccountState.IncompleteMigration -> {
            via as Event.Progress.IncompleteMigration
            Unit
        }
        AccountState.Authenticated -> when (via) {
            is Event.Progress.CompletedAuthentication -> {
                notifyObservers { onAuthenticated(account, via.authType) }
                refreshProfile(ignoreCache = false)
                Unit
            }
            Event.Progress.RecoveredFromAuthenticationProblem -> {
                notifyObservers { onAuthenticated(account, AuthType.Recovered) }
                refreshProfile(ignoreCache = true)
                Unit
            }
            else -> Unit
        }
        AccountState.AuthenticationProblem -> {
            notifyObservers { onAuthenticationProblems() }
        }
    }

    @Suppress("NestedBlockDepth", "LongMethod")
    private suspend fun internalStateSideEffects(
        forState: State.Active,
        via: Event
    ): Event? = when (forState.progressState) {
        ProgressState.Initializing -> {
            when (accountOnDisk) {
                is AccountOnDisk.New -> Event.Progress.AccountNotFound
                is AccountOnDisk.Restored -> {
                    when (account.isInMigrationState()) {
                        null -> Event.Progress.AccountRestored
                        InFlightMigrationState.REUSE_SESSION_TOKEN -> Event.Progress.IncompleteMigration(true)
                        InFlightMigrationState.COPY_SESSION_TOKEN -> Event.Progress.IncompleteMigration(false)
                    }
                }
            }
        }
        ProgressState.LoggingOut -> {
            resetAccount()
            Event.Progress.LoggedOut
        }
        ProgressState.BeginningAuthentication -> when (via) {
            is Event.Account.BeginPairingFlow, Event.Account.BeginEmailFlow -> {
                val pairingUrl = if (via is Event.Account.BeginPairingFlow) {
                    via.pairingUrl
                } else {
                    null
                }
                val result = withRetries(logger, MAX_NETWORK_RETRIES) {
                    pairingUrl.asAuthFlowUrl(account, scopes)
                }
                when (result) {
                    is Result.Success -> {
                        latestAuthState = result.value!!.state
                        oauthObservers.notifyObservers { onBeginOAuthFlow(result.value) }
                        null
                    }
                    Result.Failure -> {
                        resetAccount()
                        oauthObservers.notifyObservers { onError() }
                        Event.Progress.FailedToBeginAuth
                    }
                }
            }
            else -> null
        }
        ProgressState.CompletingAuthentication -> when (via) {
            Event.Progress.AccountRestored -> {
                val authType = AuthType.Existing
                when (withServiceRetries(logger, MAX_NETWORK_RETRIES) { finalizeDevice(authType) }) {
                    ServiceResult.Ok -> {
                        // This method can "fail" for a number of reasons:
                        // - auth problems are encountered. In that case, GlobalAccountManager.authError
                        // will be invoked, which will place an AuthenticationError event on state
                        // machine's queue.
                        // If that happens, we'll end up either in an Authenticated state
                        // (if we're able to auto-recover) or in a 'AuthenticationProblem' state otherwise.
                        // In both cases, the 'CompletedAuthentication' event below will be discarded.
                        // - network errors are encountered. 'CompletedAuthentication' event will be processed,
                        // moving the state machine into an 'Authenticated' state. Next time user requests
                        // a sync, methods that failed will be re-ran, giving them a chance to succeed.
                        authenticationSideEffects(authType)
                        Event.Progress.CompletedAuthentication(authType)
                    }
                    ServiceResult.AuthError -> {
                        Event.Account.AuthenticationError("finalizeDevice")
                    }
                    ServiceResult.OtherError -> {
                        Event.Progress.FailedToCompleteAuthRestore
                    }
                }
            }
            is Event.Progress.AuthData -> {
                val completeAuth = suspend {
                    withRetries(logger, MAX_NETWORK_RETRIES) {
                        account.completeOAuthFlow(via.authData.code, via.authData.state)
                    }
                }
                val finalize = suspend {
                    withRetries(logger, MAX_NETWORK_RETRIES) { finalizeDevice(via.authData.authType) }
                }
                // If we can't 'complete', we won't run 'finalize' due to short-circuiting.
                if (completeAuth() is Result.Failure || finalize() is Result.Failure) {
                    resetAccount()
                    Event.Progress.FailedToCompleteAuth
                } else {
                    via.authData.declinedEngines?.let { persistDeclinedEngines(it) }
                    authenticationSideEffects(via.authData.authType)
                    Event.Progress.CompletedAuthentication(via.authData.authType)
                }
            }
            is Event.Progress.Migrated -> {
                val authType = when (via.reusedSessionToken) {
                    true -> AuthType.MigratedReuse
                    false -> AuthType.MigratedCopy
                }
                when (withRetries(logger, MAX_NETWORK_RETRIES) { finalizeDevice(authType) }) {
                    is Result.Success -> {
                        authenticationSideEffects(authType)
                        Event.Progress.CompletedAuthentication(authType)
                    }
                    Result.Failure -> {
                        resetAccount()
                        Event.Progress.FailedToCompleteMigration
                    }
                }
            }
            else -> null
        }
        ProgressState.MigratingAccount -> when (via) {
            Event.Account.RetryMigration -> {
                val migrationState = account.isInMigrationState()
                if (migrationState == null) {
                    // Expected to see ourselves in a migration state, but we weren't.
                    Event.Progress.FailedToCompleteMigration
                } else {
                    tryToMigrate(migrationState.reuseSessionToken) {
                        account.retryMigrateFromSessionToken()
                    }
                }
            }
            is Event.Account.MigrateFromAccount -> {
                tryToMigrate(via.reuseSessionToken) {
                    account.migrateFromAccount(via.account.authInfo, via.reuseSessionToken)
                }
            }
            is Event.Progress.IncompleteMigration -> {
                tryToMigrate(via.reuseSessionToken) {
                    account.retryMigrateFromSessionToken()
                }
            }
            else -> null
        }
        ProgressState.RecoveringFromAuthProblem -> {
            via as Event.Account.AuthenticationError
            // Somewhere in the system, we've just hit an authentication problem.
            // There are two main causes:
            // 1) an access token we've obtain from fxalib via 'getAccessToken' expired
            // 2) password was changed, or device was revoked
            // We can recover from (1) and test if we're in (2) by asking the fxalib
            // to give us a new access token. If it succeeds, then we can go back to whatever
            // state we were in before. Future operations that involve access tokens should
            // succeed.
            // If we fail with a 401, then we know we have a legitimate authentication problem.
            logger.info("Hit auth problem. Trying to recover.")

            // Ensure we clear any auth-relevant internal state, such as access tokens.
            account.authErrorDetected()

            // Clear our access token cache; it'll be re-populated as part of the
            // regular state machine flow if we manage to recover.
            SyncAuthInfoCache(context).clear()

            // Circuit-breaker logic to protect ourselves from getting into endless authorization
            // check loops. If we determine that application is trying to check auth status too
            // frequently, drive the state machine into an unauthorized state.
            if (via.errorCountWithinTheTimeWindow >= AUTH_CHECK_CIRCUIT_BREAKER_COUNT) {
                crashReporter?.submitCaughtException(
                    AccountManagerException.AuthRecoveryCircuitBreakerException(via.operation)
                )
                logger.warn("Unable to recover from an auth problem, triggered a circuit breaker.")

                Event.Progress.FailedToRecoverFromAuthenticationProblem
            } else {
                // Since we haven't hit the circuit-breaker above, perform an authorization check.
                // We request an access token for a "profile" scope since that's the only
                // scope we're guaranteed to have at this point. That is, we don't rely on
                // passed-in application-specific scopes.
                when (account.checkAuthorizationStatus(SCOPE_PROFILE)) {
                    true -> {
                        logger.info("Able to recover from an auth problem.")

                        // And now we can go back to our regular programming.
                        Event.Progress.RecoveredFromAuthenticationProblem
                    }
                    null, false -> {
                        // We are either certainly in the scenario (2), or were unable to determine
                        // our connectivity state. Let's assume we need to re-authenticate.
                        // This uncertainty about real state means that, hopefully rarely,
                        // we will disconnect users that hit transient network errors during
                        // an authorization check.
                        // See https://github.com/mozilla-mobile/android-components/issues/3347
                        logger.info("Unable to recover from an auth problem, notifying observers.")

                        Event.Progress.FailedToRecoverFromAuthenticationProblem
                    }
                }
            }
        }
    }

    /**
     * Side-effects matrix. Defines non-pure operations that must take place for state+event combinations.
     */
    @Suppress("ComplexMethod", "ReturnCount", "ThrowsCount", "NestedBlockDepth", "LongMethod")
    private suspend fun stateActions(forState: State, via: Event): Event? = when (forState) {
        // We're about to enter a new state ('forState') via some event ('via').
        // States will have certain side-effects associated with different event transitions.
        // In other words, the same state may have different side-effects depending on the event
        // which caused a transition.
        // For example, a "NotAuthenticated" state may be entered after a logout, and its side-effects
        // will include clean-up and re-initialization of an account. Alternatively, it may be entered
        // after we've checked local disk, and didn't find a persisted authenticated account.
        is State.Idle -> {
            accountStateSideEffects(forState, via)
            null
        }
        is State.Active -> internalStateSideEffects(forState, via)
    }

    private suspend fun tryToMigrate(
        reuseSessionToken: Boolean,
        migrationBlock: suspend () -> JSONObject?
    ): Event.Progress {
        return if (migrationBlock() != null) {
            Event.Progress.Migrated(reuseSessionToken)
        } else {
            // null json means 'migrationBlock' call above failed. We expect account to be still
            // in a migrating state, and if it isn't declare this migration a failure.
            if (account.isInMigrationState() == null) {
                resetAccount()
                Event.Progress.FailedToCompleteMigration
            } else {
                Event.Progress.IncompleteMigration(reuseSessionToken)
            }
        }
    }

    private suspend fun resetAccount() {
        // Clean up internal account state and destroy the current FxA device record (if one exists).
        // This can fail (network issues, auth problems, etc), but nothing we can do at this point.
        account.disconnect()

        // Clean up resources.
        profile = null
        // Delete persisted state.
        getAccountStorage().clear()
        // Even though we might not have Sync enabled, clear out sync-related storage
        // layers as well; if they're already empty (unused), nothing bad will happen
        // and extra overhead is quite small.
        SyncAuthInfoCache(context).clear()
        SyncEnginesStorage(context).clear()
        clearSyncState(context)
    }

    private suspend fun maybeUpdateSyncAuthInfoCache() {
        // Update cached sync auth info only if we have a syncConfig (e.g. sync is enabled)...
        if (syncConfig == null) {
            return
        }

        // .. and our cache is stale.
        val cache = SyncAuthInfoCache(context)
        if (!cache.expired()) {
            return
        }

        val accessToken = account.getAccessToken(SCOPE_SYNC)
        val tokenServerUrl = if (accessToken != null) {
            // Only try to get the endpoint if we have an access token.
            account.getTokenServerEndpointURL()
        } else {
            null
        }

        if (accessToken != null && tokenServerUrl != null) {
            SyncAuthInfoCache(context).setToCache(accessToken.asSyncAuthInfo(tokenServerUrl))
        } else {
            // At this point, SyncAuthInfoCache may be entirely empty. In this case, we won't be
            // able to sync via the background worker. We will attempt to populate SyncAuthInfoCache
            // again in `syncNow` (in response to a direct user action) and after application restarts.
            logger.warn("Couldn't populate SyncAuthInfoCache. Sync may not work.")
            logger.warn("Is null? - accessToken: ${accessToken == null}, tokenServerUrl: ${tokenServerUrl == null}")
        }
    }

    private fun persistDeclinedEngines(declinedEngines: Set<SyncEngine>) {
        // Sync may not be configured at all (e.g. won't run), but if we received a
        // list of declined engines, that indicates user intent, so we preserve it
        // within SyncEnginesStorage regardless. If sync becomes enabled later on,
        // we will be respecting user choice around what to sync.
        val enginesStorage = SyncEnginesStorage(context)
        declinedEngines.forEach { declinedEngine ->
            enginesStorage.setStatus(declinedEngine, false)
        }

        // Enable all engines that were not explicitly disabled. Only do this in
        // the presence of a "declinedEngines" list, since that indicates user
        // intent. Absence of that list means that user was not presented with a
        // choice during authentication, and so we can't assume an enabled state
        // for any of the engines.
        syncConfig?.supportedEngines?.forEach { supportedEngine ->
            if (!declinedEngines.contains(supportedEngine)) {
                enginesStorage.setStatus(supportedEngine, true)
            }
        }
    }

    private suspend fun finalizeDevice(authType: AuthType) = account.deviceConstellation().finalizeDevice(
        authType, deviceConfig
    )

    private suspend fun authenticationSideEffects(authType: AuthType) {
        // Make sure our SyncAuthInfo cache is hot, background sync worker needs it to function.
        maybeUpdateSyncAuthInfoCache()

        // Sync workers also need to know about the current FxA device.
        FxaDeviceSettingsCache(context).setToCache(DeviceSettings(
            fxaDeviceId = account.getCurrentDeviceId()!!,
            name = deviceConfig.name,
            type = deviceConfig.type.intoSyncType()
        ))

        // If device supports SEND_TAB, and we're not recovering from an auth problem...
        if (deviceConfig.capabilities.contains(DeviceCapability.SEND_TAB) && authType != AuthType.Recovered) {
            // ... update constellation state (fetching info about other devices, our own device).
            account.deviceConstellation().refreshDevices()
        }
    }

    /**
     * Exists strictly for testing purposes, allowing tests to specify their own implementation of [OAuthAccount].
     */
    @VisibleForTesting
    open fun getStorageWrapper(): StorageWrapper {
        return StorageWrapper(this, accountEventObserverRegistry, serverConfig, crashReporter)
    }

    @VisibleForTesting
    internal open fun createSyncManager(config: SyncConfig): SyncManager {
        return WorkManagerSyncManager(context, config)
    }

    internal open fun getAccountStorage(): AccountStorage {
        return if (deviceConfig.secureStateAtRest) {
            SecureAbove22AccountStorage(context, crashReporter)
        } else {
            SharedPrefAccountStorage(context, crashReporter)
        }
    }

    /**
     * Account status events flowing into the sync manager.
     */
    @VisibleForTesting
    internal class AccountsToSyncIntegration(
        private val syncManager: SyncManager
    ) : AccountObserver {
        override fun onLoggedOut() {
            syncManager.stop()
        }

        override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
            val reason = when (authType) {
                is AuthType.OtherExternal, AuthType.Signin, AuthType.Signup, AuthType.MigratedReuse,
                AuthType.MigratedCopy, AuthType.Pairing -> SyncReason.FirstSync
                AuthType.Existing, AuthType.Recovered -> SyncReason.Startup
            }
            syncManager.start()
            syncManager.now(reason)
        }

        override fun onProfileUpdated(profile: Profile) {
            // Sync currently doesn't care about the FxA profile.
            // In the future, we might kick-off an immediate sync here.
        }

        override fun onAuthenticationProblems() {
            syncManager.stop()
        }
    }

    /**
     * Sync status changes flowing into account manager.
     */
    private class SyncToAccountsIntegration(
        private val accountManager: FxaAccountManager
    ) : SyncStatusObserver {
        override fun onStarted() {
            accountManager.syncStatusObserverRegistry.notifyObservers { onStarted() }
        }

        override fun onIdle() {
            accountManager.syncStatusObserverRegistry.notifyObservers { onIdle() }
        }

        override fun onError(error: Exception?) {
            accountManager.syncStatusObserverRegistry.notifyObservers { onError(error) }
        }
    }
}
