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

import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.StoredSession
import com.runicgateway.app.core.auth.TokenStore
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 com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SafeUserDto
import com.runicgateway.app.data.api.dto.SsoProviderDto
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response

/**
 * The native SSO bridge orchestration (PLAN.md §4.2, M9): start-URL building, the
 * CSRF/state guard, the code→token exchange, and that a success drives the *same*
 * [SessionManager] the password login uses. Runs over a fake [SsoApi] + a real
 * [SessionManager] on a fake [TokenStore]; no Android framework types are touched.
 */
class SsoAuthManagerTest {

    private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore {
        override fun load(): StoredSession? = stored
        override fun save(session: StoredSession) { stored = session }
        override fun clear() { stored = null }
    }

    /** In-memory stand-in for the encrypted pending-SSO store (survives across
     *  manager instances the way the on-disk store survives process death). */
    private class FakePendingSsoStore(var pending: PendingSso? = null) : PendingSsoStore {
        override fun save(state: String, verifier: String) { pending = PendingSso(state, verifier) }
        override fun load(): PendingSso? = pending
        override fun clear() { pending = null }
    }

    /** In-memory stand-in for the encrypted trust-token store, scoped by username
     *  the same way the production impl is. */
    private class FakeTrustTokenStore : TrustTokenStore {
        var owner: String? = null
        var token: String? = null
        override fun tokenFor(username: String): String? =
            if (owner.equals(username, ignoreCase = true)) token else null
        override fun save(username: String, token: String) { owner = username; this.token = token }
        override fun clear() { owner = null; token = null }
    }

    /** Records the exchange it was called with and returns a scripted response. */
    private class FakeSsoApi(
        private val exchangeResult: () -> Response<MobileTokenResponse>,
    ) : SsoApi {
        var exchangeCalls = 0
        var lastRequest: MobileSsoExchangeRequest? = null

        override suspend fun providers(): List<SsoProviderDto> = emptyList()

        override suspend fun exchange(body: MobileSsoExchangeRequest): Response<MobileTokenResponse> {
            exchangeCalls++
            lastRequest = body
            return exchangeResult()
        }
    }

    private fun tokenPair() = MobileTokenResponse(
        accessToken = "access-A",
        refreshToken = "refresh-A",
        expiresIn = "15m",
        user = SafeUserDto(id = 7, username = "alice", role = "player"),
    )

    private fun error(code: Int): Response<MobileTokenResponse> =
        Response.error(code, "".toResponseBody("application/json".toMediaTypeOrNull()))

    private fun managerWith(
        api: SsoApi,
        session: SessionManager,
        base: String? = "https://shard.example.com/",
        store: PendingSsoStore = FakePendingSsoStore(),
        trust: TrustTokenStore = FakeTrustTokenStore(),
    ): SsoAuthManager {
        val holder = BaseUrlHolder()
        if (base != null) holder.set(base.toHttpUrl())
        return SsoAuthManager(api, session, holder, store, trust)
    }

    /** Build a start URL and pull the generated `state` back out of it. */
    private fun startAndState(mgr: SsoAuthManager, provider: String = "google"): String {
        val url = mgr.buildStartUrl(SsoProviderDto(id = provider, name = "Google").id)!!
        return url.toHttpUrl().queryParameter("state")!!
    }

    @Test fun `buildStartUrl carries provider, challenge, state and the fixed redirect`() {
        val mgr = managerWith(FakeSsoApi { tokenPair().let { Response.success(it) } }, SessionManager(FakeTokenStore()))
        val url = mgr.buildStartUrl("google")!!
        val http = url.toHttpUrl()

        assertTrue(url.startsWith("https://shard.example.com/api/v1/auth/mobile/sso/start"))
        assertEquals("google", http.queryParameter("provider"))
        assertEquals(SsoAuthManager.REDIRECT_URI, http.queryParameter("redirect_uri"))
        assertTrue(!http.queryParameter("code_challenge").isNullOrBlank())
        assertTrue(!http.queryParameter("state").isNullOrBlank())
    }

    @Test fun `buildStartUrl returns null when no base url is set`() {
        val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
        assertNull(mgr.buildStartUrl("google"))
    }

    @Test fun `successful callback exchanges and signs in`() = runTest {
        val api = FakeSsoApi { Response.success(tokenPair()) }
        val session = SessionManager(FakeTokenStore())
        val mgr = managerWith(api, session)

        val state = startAndState(mgr)
        mgr.complete(state = state, code = "auth-code-1", error = null)

        assertEquals(1, api.exchangeCalls)
        assertEquals("auth-code-1", api.lastRequest?.code)
        assertTrue(session.state.value is Session.SignedIn)
        assertEquals("access-A", session.currentAccessToken())
        assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
    }

    // Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
    // on the TOTP form inside the Custom Tab trusts that browser via cookie; the
    // exchange additionally hands the APP its own token so a native password login
    // on this device skips the code too. Before this, SSO ignored trust entirely.
    @Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
        val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
        val session = SessionManager(FakeTokenStore())
        val trust = FakeTrustTokenStore()
        val mgr = managerWith(api, session, trust = trust)

        val state = startAndState(mgr)
        mgr.complete(state = state, code = "auth-code-1", error = null)

        assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
        assertEquals("opaque-trust", trust.tokenFor("alice"))
        // Scoped to the account that minted it — never replayed for someone else.
        assertNull(trust.tokenFor("mallory"))
    }

    @Test fun `no trustToken on the response leaves the store untouched`() = runTest {
        val api = FakeSsoApi { Response.success(tokenPair()) }
        val session = SessionManager(FakeTokenStore())
        val trust = FakeTrustTokenStore()
        val mgr = managerWith(api, session, trust = trust)

        val state = startAndState(mgr)
        mgr.complete(state = state, code = "auth-code-1", error = null)

        assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
        assertNull(trust.tokenFor("alice"))
    }

    @Test fun `state mismatch fails without exchanging`() = runTest {
        val api = FakeSsoApi { Response.success(tokenPair()) }
        val session = SessionManager(FakeTokenStore())
        val mgr = managerWith(api, session)

        startAndState(mgr) // establishes a pending with a different state
        mgr.complete(state = "not-the-state", code = "auth-code-1", error = null)

        assertEquals(0, api.exchangeCalls)
        assertTrue(session.state.value is Session.SignedOut)
        assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
    }

    @Test fun `missing pending flow (process death) fails closed`() = runTest {
        val api = FakeSsoApi { Response.success(tokenPair()) }
        val mgr = managerWith(api, SessionManager(FakeTokenStore()))

        // No buildStartUrl → nothing stashed; a callback can't be trusted.
        mgr.complete(state = "anything", code = "auth-code-1", error = null)

        assertEquals(0, api.exchangeCalls)
        assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
    }

    @Test fun `pending survives process death — a fresh manager on the same store completes`() = runTest {
        // Persist the pending on one instance, then throw that instance away.
        val store = FakePendingSsoStore()
        val session = SessionManager(FakeTokenStore())
        val started = managerWith(FakeSsoApi { Response.success(tokenPair()) }, session, store = store)
        val state = startAndState(started)

        // A brand-new manager (simulating the app relaunched after eviction) reads the
        // persisted pending and completes the exchange — the old in-memory holder would
        // have lost it and failed STATE_MISMATCH.
        val api = FakeSsoApi { Response.success(tokenPair()) }
        val revived = managerWith(api, session, store = store)
        revived.complete(state = state, code = "auth-code-1", error = null)

        assertEquals(1, api.exchangeCalls)
        assertTrue(session.state.value is Session.SignedIn)
        assertEquals(SsoAuthManager.Outcome.Success, revived.outcome.value)
    }

    @Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest {
        val api = FakeSsoApi { Response.success(tokenPair()) }
        val mgr = managerWith(api, SessionManager(FakeTokenStore()))

        val state = startAndState(mgr)
        mgr.complete(state = state, code = null, error = "access_denied")

        assertEquals(0, api.exchangeCalls)
        assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.DENIED), mgr.outcome.value)
    }

    @Test fun `401 exchange maps to expired code`() = runTest {
        val api = FakeSsoApi { error(401) }
        val session = SessionManager(FakeTokenStore())
        val mgr = managerWith(api, session)

        val state = startAndState(mgr)
        mgr.complete(state = state, code = "stale-code", error = null)

        assertEquals(1, api.exchangeCalls)
        assertTrue(session.state.value is Session.SignedOut)
        assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.EXPIRED_CODE), mgr.outcome.value)
    }

    @Test fun `a second delivery of the same callback finds no pending`() = runTest {
        val api = FakeSsoApi { Response.success(tokenPair()) }
        val mgr = managerWith(api, SessionManager(FakeTokenStore()))

        val state = startAndState(mgr)
        mgr.complete(state = state, code = "auth-code-1", error = null)
        mgr.complete(state = state, code = "auth-code-1", error = null) // replay

        // Only the first delivery exchanged; the replay fails the state guard.
        assertEquals(1, api.exchangeCalls)
        assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
    }

    @Test fun `matchesCallback only accepts the fixed scheme host and path`() {
        val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
        assertTrue(mgr.matchesCallback("runicgateway", "auth", "/callback"))
        assertTrue(!mgr.matchesCallback("https", "auth", "/callback"))
        assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other"))
        assertTrue(!mgr.matchesCallback("runicgateway", "evil", "/callback"))
    }

    // ── App Links (docs/android/APP_LINKS.md) ────────────────────────────────

    @Test fun `matchesAppLinkCallback accepts only https, the app-link path, and the paired host`() {
        val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
        // Paired to shard.example.com (managerWith default base).
        assertTrue(mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
        // Host-trust: a foreign host is refused even over https + right path.
        assertTrue(!mgr.matchesAppLinkCallback("https", "evil.example.com", "/mobile/callback"))
        // Wrong scheme / wrong path.
        assertTrue(!mgr.matchesAppLinkCallback("http", "shard.example.com", "/mobile/callback"))
        assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/callback"))
        // Host match is case-insensitive.
        assertTrue(mgr.matchesAppLinkCallback("https", "SHARD.EXAMPLE.COM", "/mobile/callback"))
    }

    @Test fun `matchesAppLinkCallback is false before a shard is paired`() {
        val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
        assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
    }

    @Test fun `buildStartUrl requests the custom scheme when no app-link host is baked`() {
        val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
        // Generic build: appLinkHost defaults to BuildConfig.APP_LINK_HOST ("" in tests).
        val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
        assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
    }

    @Test fun `buildStartUrl requests the https app-link callback when the baked host matches the paired host`() {
        val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
        mgr.appLinkHost = "shard.example.com" // white-label build baked this shard's host
        val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
        assertEquals("https://shard.example.com/mobile/callback", redirect)
    }

    @Test fun `buildStartUrl falls back to the custom scheme when the baked host does not match the paired shard`() {
        val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
        mgr.appLinkHost = "other-shard.example.com" // built for a different shard than the paired one
        val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
        assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
    }
}
