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

import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileLoginRequest
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Native username/password (+TOTP) auth — the app's only native credential flow
 * (PLAN.md §4.1). It drives the [SessionManager]: a successful login establishes
 * the session; logout revokes it. Registration/invite/reset/SSO are website
 * hand-offs (§4.2), not here.
 */
@Singleton
class AuthRepository @Inject constructor(
    private val authApi: AuthApi,
    private val ssoApi: SsoApi,
    private val sessionManager: SessionManager,
    private val pushManager: PushManager,
    private val trustTokenStore: TrustTokenStore,
    private val deviceNameProvider: DeviceNameProvider,
    private val json: Json,
) {

    /** The three outcomes of SSO provider discovery, so the login screen can tell a
     *  shard that offers no SSO ([None]) apart from a discovery that failed
     *  ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
     *  a broken call behind a dead website hand-off (§4.2). */
    sealed interface SsoDiscovery {
        /** At least one enabled provider — render a native button per entry. */
        data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery

        /** Discovery succeeded but the shard has no SSO providers configured. */
        data object None : SsoDiscovery

        /** The discovery call failed (offline / server error) — surface a retry. */
        data object Unavailable : SsoDiscovery
    }

    /**
     * Discover the shard's enabled SSO providers for the native login buttons (§4.2).
     * Public discovery, never secrets. Retries once before reporting [Unavailable],
     * so a single transient blip doesn't strand the user.
     */
    suspend fun ssoProviders(): SsoDiscovery {
        var lastFailed = false
        repeat(2) { attempt ->
            try {
                val providers = ssoApi.providers()
                return if (providers.isEmpty()) SsoDiscovery.None else SsoDiscovery.Available(providers)
            } catch (e: CancellationException) {
                throw e
            } catch (_: Exception) {
                lastFailed = true
                if (attempt == 0) delay(DISCOVERY_RETRY_DELAY_MS)
            }
        }
        return if (lastFailed) SsoDiscovery.Unavailable else SsoDiscovery.None
    }

    /** Outcome of a login attempt (§4.1). */
    sealed interface LoginResult {
        /**
         * Signed in. [trustLimitReached] is true when "trust this device" was asked
         * for but the per-user cap blocked it (the login still succeeded, but no trust
         * token was issued); [devices] then lists the trusted devices to manage.
         */
        data class Success(
            val trustLimitReached: Boolean = false,
            val devices: List<TrustedDeviceDto> = emptyList(),
        ) : LoginResult

        /** The account has 2FA on — reveal the code field and resubmit with a code. */
        data object TotpRequired : LoginResult
        data object InvalidCredentials : LoginResult

        /** Guarded by per-IP backoff → slowdown → hard cap; back off and retry. */
        data object RateLimited : LoginResult

        /** Any other server failure (5xx / unexpected). */
        data object ServerError : LoginResult

        /** No answer — offline, DNS, TLS, timeout. */
        data object NetworkError : LoginResult
    }

    /**
     * Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
     * rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
     * second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
     * [trustDevice], the server may return a fresh trust token to persist for next time.
     */
    suspend fun login(
        username: String,
        password: String,
        code: String? = null,
        recoveryCode: String? = null,
        trustDevice: Boolean = false,
    ): LoginResult {
        val storedTrustToken = trustTokenStore.tokenFor(username)
        val response: Response<MobileTokenResponse> = try {
            authApi.login(
                MobileLoginRequest(
                    username = username,
                    password = password,
                    code = code,
                    recoveryCode = recoveryCode,
                    trustDevice = trustDevice.takeIf { it },
                    device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
                ),
                trustToken = storedTrustToken,
            )
        } catch (e: CancellationException) {
            throw e
        } catch (_: IOException) {
            return LoginResult.NetworkError
        }

        if (response.isSuccessful) {
            val body = response.body() ?: return LoginResult.ServerError
            // Persist a freshly minted trust token (scoped to this account) so the next
            // login skips the second factor — it deliberately outlives logout.
            body.trustToken?.let { trustTokenStore.save(username, it) }
            sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
            return LoginResult.Success(
                trustLimitReached = body.trustLimitReached,
                devices = body.devices,
            )
        }

        return when (response.code()) {
            401 -> if (isTotpRequired(response)) LoginResult.TotpRequired else LoginResult.InvalidCredentials
            429 -> LoginResult.RateLimited
            else -> LoginResult.ServerError
        }
    }

    /**
     * Persist a trust token minted by the self-service "trust this device" action
     * (Account → Trusted Devices), scoped to [username] exactly like the login path.
     */
    fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)

    /**
     * Drop the locally stored trust token so this device stops skipping the TOTP step
     * (used after "untrust all" and on a Settings → Server switch). Server-side
     * revocation makes any surviving token inert anyway — the next login just prompts
     * for the code — so this is a client-side cleanliness step, never load-bearing.
     */
    fun clearTrustToken() = trustTokenStore.clear()

    /**
     * Revoke this session (or, with [allDevices], every session) and clear local
     * tokens (§4.3). Best-effort: the local session is torn down even if the
     * network call fails, so the user is always signed out locally.
     */
    suspend fun logout(allDevices: Boolean = false) {
        // Deregister this device's push endpoint while the bearer is still valid, so
        // no orphan device row is left behind (§11). Keeps the opt-in intent so push
        // resumes on the next sign-in; best-effort, never blocks the logout.
        try {
            pushManager.deregisterDevice()
        } catch (e: CancellationException) {
            throw e
        } catch (_: Exception) {
            // Ignore — local session teardown proceeds regardless.
        }
        val refreshToken = sessionManager.currentRefreshToken()
        try {
            authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
        } catch (e: CancellationException) {
            throw e
        } catch (_: Exception) {
            // Ignore — we still drop the local session below.
        }
        sessionManager.onSignedOut()
    }

    /**
     * Re-validate the session against `GET /auth/me` on app resume (§4.3). A
     * success refreshes the cached role (roles change server-side); a `401` that
     * survives the silent refresh means the session is dead → sign out. Transient
     * failures are ignored so a flaky network doesn't bounce the user.
     */
    suspend fun revalidate() {
        if (!sessionManager.isSignedIn) return
        try {
            val me = authApi.me()
            sessionManager.onUserRefreshed(me.user)
        } catch (e: CancellationException) {
            throw e
        } catch (e: retrofit2.HttpException) {
            if (e.code() == 401) sessionManager.onSignedOut()
        } catch (_: IOException) {
            // Offline — keep the session; the next authed call will re-check.
        }
    }

    private fun isTotpRequired(response: Response<*>): Boolean = try {
        val raw = response.errorBody()?.string()
        !raw.isNullOrBlank() && json.decodeFromString<TotpRequiredError>(raw).totpRequired
    } catch (_: Exception) {
        false
    }

    private companion object {
        const val DISCOVERY_RETRY_DELAY_MS = 400L
    }
}
