/*
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package com.runicgateway.app.ui.auth

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.web.WebsiteUrls
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

/**
 * Drives the native login screen (PLAN.md §4.1): username/password, single-request
 * TOTP (the code field is revealed once the backend answers `totpRequired`), and
 * friendly 429 backoff handling. Registration / forgot-password / SSO are website
 * hand-offs whose URLs it exposes (§4.2).
 */
@HiltViewModel
class LoginViewModel @Inject constructor(
    private val authRepository: AuthRepository,
    private val ssoAuthManager: SsoAuthManager,
    private val websiteUrls: WebsiteUrls,
) : ViewModel() {

    /** The transient error surfaced under the form after a failed attempt. */
    enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK, SSO }

    data class UiState(
        val username: String = "",
        val password: String = "",
        val code: String = "",
        /** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
        val recoveryCode: String = "",
        /** True once the account is known to have 2FA on — reveal the code field. */
        val totpRequired: Boolean = false,
        /** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
        val useRecoveryCode: Boolean = false,
        /** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
        val trustDevice: Boolean = false,
        val submitting: Boolean = false,
        val error: LoginError? = null,
        val signedIn: Boolean = false,
        /** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
        val ssoProviders: List<SsoProviderDto> = emptyList(),
        /** True while discovery is in flight — the screen shows a spinner, not an empty gap. */
        val ssoDiscovering: Boolean = true,
        /** True when discovery failed (offline/server) — offer a retry rather than a dead end. */
        val ssoUnavailable: Boolean = false,
        /** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
        val ssoLaunchUrl: String? = null,
    )

    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()

    init {
        discoverSsoProviders()
        // Consume the SSO bridge outcome: a returned callback completes here even if
        // this ViewModel was recreated while the Custom Tab was foreground (§4.2).
        viewModelScope.launch {
            ssoAuthManager.outcome.collect { outcome ->
                when (outcome) {
                    SsoAuthManager.Outcome.Success -> {
                        ssoAuthManager.consumeOutcome()
                        _state.update { it.copy(submitting = false, signedIn = true) }
                    }
                    is SsoAuthManager.Outcome.Failed -> {
                        ssoAuthManager.consumeOutcome()
                        _state.update { it.copy(submitting = false, error = mapSsoError(outcome.reason)) }
                    }
                    SsoAuthManager.Outcome.Idle -> Unit
                }
            }
        }
    }

    fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
    fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
    fun onCodeChange(value: String) =
        _state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }

    /** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
    fun onRecoveryCodeChange(value: String) =
        _state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }

    fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }

    /** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
    fun onUseRecoveryCodeChange(value: Boolean) =
        _state.update { it.copy(useRecoveryCode = value, error = null) }

    val registerUrl: String? get() = websiteUrls.register()
    val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()

    /**
     * Discover the shard's native SSO providers (§4.2). A failure surfaces a retry
     * affordance instead of the old dead website-login hand-off, which was never
     * mobile-formatted and could not deep-link the session back.
     */
    fun discoverSsoProviders() {
        _state.update { it.copy(ssoDiscovering = true, ssoUnavailable = false) }
        viewModelScope.launch {
            when (val result = authRepository.ssoProviders()) {
                is SsoDiscovery.Available ->
                    _state.update {
                        it.copy(ssoProviders = result.providers, ssoDiscovering = false, ssoUnavailable = false)
                    }
                SsoDiscovery.None ->
                    _state.update {
                        it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = false)
                    }
                SsoDiscovery.Unavailable ->
                    _state.update {
                        it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = true)
                    }
            }
        }
    }

    /**
     * Begin a native SSO flow for [provider]: mint PKCE + state and surface the
     * `/start` URL for the screen to open in a Custom Tab. No-op (leaves a SERVER
     * error) if the base URL isn't set yet — the website fallback still shows.
     */
    fun onSsoProviderClick(provider: SsoProviderDto) {
        if (_state.value.submitting) return
        val url = ssoAuthManager.buildStartUrl(provider.id)
        if (url == null) {
            _state.update { it.copy(error = LoginError.SSO) }
            return
        }
        _state.update { it.copy(error = null, ssoLaunchUrl = url) }
    }

    /** The screen has opened the Custom Tab; clear so it isn't re-launched on recompose. */
    fun onSsoLaunchConsumed() = _state.update { it.copy(ssoLaunchUrl = null) }

    private fun mapSsoError(reason: SsoAuthManager.Failure): LoginError = when (reason) {
        SsoAuthManager.Failure.NETWORK -> LoginError.NETWORK
        else -> LoginError.SSO
    }

    fun submit() {
        val s = _state.value
        if (s.submitting) return
        val validationError = validateForSubmit(s)
        if (validationError != null) {
            _state.update { it.copy(error = validationError) }
            return
        }

        _state.update { it.copy(submitting = true, error = null) }
        viewModelScope.launch {
            // Only one second factor is sent; the recovery toggle picks which.
            val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
            val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
            val result = authRepository.login(
                username = s.username.trim(),
                password = s.password,
                code = code,
                recoveryCode = recoveryCode,
                trustDevice = s.trustDevice,
            )
            applyLoginResult(result)
        }
    }

    /** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
    private fun validateForSubmit(s: UiState): LoginError? {
        if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
        // If 2FA is being requested, the chosen second factor must accompany the resubmit.
        if (s.totpRequired) {
            val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
            if (factor.isBlank()) return LoginError.BAD_CODE
        }
        return null
    }

    /** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
    private fun applyLoginResult(result: LoginResult) = when (result) {
        is LoginResult.Success ->
            // The trusted-device cap (result.trustLimitReached) is an edge case:
            // login succeeded but the device wasn't remembered. It's surfaced +
            // managed on the Trusted Devices screen rather than blocking sign-in.
            _state.update { it.copy(submitting = false, signedIn = true) }

        LoginResult.TotpRequired ->
            // Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
            _state.update {
                val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
                it.copy(
                    submitting = false,
                    totpRequired = true,
                    error = if (hadFactor) LoginError.BAD_CODE else null,
                )
            }

        LoginResult.InvalidCredentials ->
            _state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }

        LoginResult.RateLimited ->
            _state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }

        LoginResult.ServerError ->
            _state.update { it.copy(submitting = false, error = LoginError.SERVER) }

        LoginResult.NetworkError ->
            _state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
    }
}
