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

import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Orchestrates the native "Sign in with Google/Discord" flow — the app half of the
 * Mobile SSO Authorization Bridge (PLAN.md §4.2, BACKEND_DESIGN "Mobile SSO
 * Authorization Bridge"). It never adds a parallel auth path: a successful exchange
 * drives the *same* [SessionManager.onSignedIn] the password login uses, so the
 * menu, push registration, and re-validation all react identically.
 *
 * The flow:
 *  1. [buildStartUrl] mints PKCE (Layer B) + a CSRF `state`, stashes them, and
 *     returns the `/auth/mobile/sso/start` URL the caller opens in a Custom Tab.
 *  2. The website bounces through the IdP and deep-links back to
 *     [REDIRECT_URI] with `?code&state` (success) or `?error&state` (failure).
 *  3. [complete] verifies `state`, exchanges the `code` with the stashed verifier,
 *     and signs the user in — publishing the result on [outcome]. `MainActivity`
 *     parses the callback `Uri` (the Android edge) and hands the raw params here,
 *     so this class stays free of framework types and unit-tests on the JVM.
 *
 * The pending `{state, verifier}` is persisted via [PendingSsoStore] (encrypted at
 * rest), so the exchange survives the process being evicted while the Custom Tab is
 * foreground — the callback can land in a fresh process and still complete. It is
 * cleared the moment [complete] consumes it, so a lost/duplicate callback still
 * **fails closed** as [Failure.STATE_MISMATCH] rather than double-exchanging.
 *
 * Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
 * activity's coroutine scope after a deep link. [outcome] is a [StateFlow], so a
 * ViewModel/activity recreation while the Custom Tab is open cannot drop a result.
 */
@Singleton
class SsoAuthManager @Inject constructor(
    private val ssoApi: SsoApi,
    private val sessionManager: SessionManager,
    private val baseUrlHolder: BaseUrlHolder,
    private val pendingStore: PendingSsoStore,
    private val trustTokenStore: TrustTokenStore,
) {

    /** Why an SSO attempt ended, for a friendly inline message on the login screen. */
    enum class Failure {
        /** The user cancelled or the IdP/website refused (e.g. no linked account). */
        DENIED,

        /** The callback `state` didn't match — CSRF guard, or the pending flow was lost. */
        STATE_MISMATCH,

        /** The one-time code was unknown / expired / already used, or PKCE failed. */
        EXPIRED_CODE,

        /** Offline / DNS / TLS / timeout during the exchange. */
        NETWORK,

        /** Any other server failure, or a missing base URL / malformed callback. */
        SERVER,
    }

    /** The observable result of the most recent flow; the login screen consumes it. */
    sealed interface Outcome {
        data object Idle : Outcome
        data object Success : Outcome
        data class Failed(val reason: Failure) : Outcome
    }

    /**
     * The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
     * empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
     * `internal var` only so unit tests can exercise the App Link path without a build
     * flavor; production never reassigns it.
     */
    internal var appLinkHost: String = BuildConfig.APP_LINK_HOST

    private val _outcome = MutableStateFlow<Outcome>(Outcome.Idle)
    val outcome: StateFlow<Outcome> = _outcome.asStateFlow()

    /** Ack a delivered [outcome] so it isn't re-handled after a recomposition. */
    fun consumeOutcome() {
        _outcome.value = Outcome.Idle
    }

    /**
     * Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
     * PKCE verifier + CSRF state (persisted so it survives process death). Returns
     * null when no shard site is configured yet. Also resets [outcome] to
     * [Outcome.Idle] so a stale prior result can't fire against the new attempt.
     */
    fun buildStartUrl(providerId: String): String? {
        val base = baseUrlHolder.current ?: return null
        val verifier = Pkce.newVerifier()
        val challenge = Pkce.challengeOf(verifier)
        val state = Pkce.newState()
        pendingStore.save(state = state, verifier = verifier)
        _outcome.value = Outcome.Idle
        return base.newBuilder()
            .addPathSegments("api/v1/auth/mobile/sso/start")
            .addQueryParameter("provider", providerId)
            .addQueryParameter("code_challenge", challenge)
            .addQueryParameter("state", state)
            .addQueryParameter("redirect_uri", redirectUriFor(base.host))
            .build()
            .toString()
    }

    /**
     * The `redirect_uri` to request for a shard on [pairedHost]: the verified https
     * App Link callback **iff** this build baked an App Link host that matches the
     * paired host (a white-label/first-party build for exactly this shard — which is
     * also responsible for enabling `mobile_app_links_enabled` server-side); otherwise
     * the fixed custom-scheme callback, which every build/shard always supports.
     */
    private fun redirectUriFor(pairedHost: String): String =
        if (appLinkHost.isNotBlank() && appLinkHost.equals(pairedHost, ignoreCase = true)) {
            "https://$pairedHost$APP_LINK_CALLBACK_PATH"
        } else {
            REDIRECT_URI
        }

    /** True if a deep link's scheme/host/path are our fixed custom-scheme SSO callback. */
    fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean =
        scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH

    /**
     * True if a deep link is a verified https App Link callback for the shard we are
     * **currently paired to**. The `host == pairedHost` check is defense-in-depth:
     * `autoVerify` already means only a real, opted-in shard domain can route here,
     * but the app still refuses an https callback whose host isn't the paired shard.
     * Returns false before a shard is configured (no paired host to trust).
     */
    fun matchesAppLinkCallback(scheme: String?, host: String?, path: String?): Boolean {
        val pairedHost = baseUrlHolder.current?.host ?: return false
        return scheme == "https" && path == APP_LINK_CALLBACK_PATH &&
            host != null && host.equals(pairedHost, ignoreCase = true)
    }

    /**
     * Handle the parsed callback params from a returned [REDIRECT_URI] deep link:
     * verify `state`, map an `error`, else exchange the `code` and sign in.
     * Publishes the result on [outcome]. Idempotent-safe: the pending is cleared on
     * entry, so a duplicate delivery of the same callback finds no pending and fails
     * as [Failure.STATE_MISMATCH] rather than double-exchanging (the backend also
     * single-uses the code).
     */
    suspend fun complete(state: String?, code: String?, error: String?) {
        val stashed = pendingStore.load()
        pendingStore.clear()

        // CSRF: the callback must echo the exact state we generated at /start.
        if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
            _outcome.value = Outcome.Failed(Failure.STATE_MISMATCH)
            return
        }

        // A website/IdP-side failure comes back as ?error=… (never with a code).
        if (!error.isNullOrEmpty()) {
            _outcome.value = Outcome.Failed(mapError(error))
            return
        }

        if (code.isNullOrBlank()) {
            _outcome.value = Outcome.Failed(Failure.SERVER)
            return
        }

        val response = try {
            ssoApi.exchange(MobileSsoExchangeRequest(code = code, codeVerifier = stashed.verifier))
        } catch (e: CancellationException) {
            throw e
        } catch (_: IOException) {
            _outcome.value = Outcome.Failed(Failure.NETWORK)
            return
        } catch (_: Exception) {
            _outcome.value = Outcome.Failed(Failure.SERVER)
            return
        }

        if (response.isSuccessful) {
            val body = response.body()
            if (body == null) {
                _outcome.value = Outcome.Failed(Failure.SERVER)
                return
            }
            // The user ticked "trust this device" on the TOTP form inside the Custom
            // Tab. That tab's cookie already covers future SSO sign-ins; persisting
            // the token the exchange handed back is what lets a native PASSWORD login
            // on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
            // username exactly like the password path, so it is never replayed for a
            // different account on a shared device. Saved BEFORE onSignedIn so a
            // process death mid-callback can't lose it.
            body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
            sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
            _outcome.value = Outcome.Success
            return
        }

        _outcome.value = Outcome.Failed(if (response.code() == 401) Failure.EXPIRED_CODE else Failure.SERVER)
    }

    // The bridge's start + callback error codes → user-facing failure reasons.
    // Start (mobileSso.controller): invalid_provider | provider_unavailable | server_error.
    // Callback (sso.controller): not_linked | disabled | session_expired | error,
    // plus a forwarded IdP access_denied.
    private fun mapError(error: String): Failure = when (error) {
        // Link-only policy refused, or the account is inactive, or the user declined.
        "not_linked", "disabled", "access_denied" -> Failure.DENIED
        // The bridge session aged out mid-flow — start over.
        "session_expired" -> Failure.EXPIRED_CODE
        // invalid_provider / provider_unavailable / server_error / error / anything else.
        else -> Failure.SERVER
    }

    companion object {
        const val CALLBACK_SCHEME = "runicgateway"
        const val CALLBACK_HOST = "auth"
        const val CALLBACK_PATH = "/callback"

        /**
         * The one fixed, application-owned callback the bridge redirects to. Must
         * match the `MOBILE_AUTH_REDIRECT_URIS` allowlist entry on the backend and
         * the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2).
         */
        const val REDIRECT_URI = "$CALLBACK_SCHEME://$CALLBACK_HOST$CALLBACK_PATH"

        /**
         * Path of the verified https App Link callback (`https://<shard-host>/mobile/callback`).
         * Must match the app's `autoVerify` intent-filter in `AndroidManifest.xml` and the
         * backend's self-origin allowlist entry (docs/android/APP_LINKS.md §3.2/§4.2).
         */
        const val APP_LINK_CALLBACK_PATH = "/mobile/callback"
    }
}
