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

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.buildNavTree
import com.runicgateway.app.ui.navigation.isEntryVisible
import com.runicgateway.app.ui.navigation.pruneNav
import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen
import com.runicgateway.app.ui.admin.AdminContentScreen
import com.runicgateway.app.ui.admin.AdminDashboardScreen
import com.runicgateway.app.ui.admin.AdminModerationScreen
import com.runicgateway.app.ui.admin.AdminSupportScreen
import com.runicgateway.app.ui.notifications.NotificationsScreen
import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen
import com.runicgateway.app.ui.player.CharactersScreen
import com.runicgateway.app.ui.player.MyHousesScreen
import com.runicgateway.app.ui.player.VendorsScreen
import com.runicgateway.app.ui.session.SessionViewModel
import com.runicgateway.app.ui.shard.AtlasCreatureScreen
import com.runicgateway.app.ui.shard.AtlasScreen
import com.runicgateway.app.ui.shard.ChampsScreen
import com.runicgateway.app.ui.shard.GovernorsScreen
import com.runicgateway.app.ui.shard.GuildsScreen
import com.runicgateway.app.ui.shard.HousesScreen
import com.runicgateway.app.ui.shard.LeaderboardsScreen
import com.runicgateway.app.ui.shard.MarketScreen
import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch

/** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf(
    Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
    // Protocol 3.0 content screens are drawer destinations, so the drawer gesture works
    // on them too (M11).
    Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
    Routes.NOTIFICATIONS,
    Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
    Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)

/**
 * The main app shell once a shard site is configured (PLAN.md §5): one shared,
 * declarative, access-level navigation drawer whose entries are filtered by the
 * current session, plus the Sign in / Sign out toggle and the Settings → Server
 * switch. The signed-in role is re-validated against the backend on every resume
 * (§4.3), so a server-side demotion drops menu access promptly.
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RunicApp(
    appearance: SiteAppearance,
    onChangeServer: () -> Unit,
    modifier: Modifier = Modifier,
    deepLinkStream: String? = null,
    onDeepLinkConsumed: () -> Unit = {},
    sessionViewModel: SessionViewModel = hiltViewModel(),
) {
    val brand = appearance.brand
    val navController = rememberNavController()
    val drawerState = rememberDrawerState(DrawerValue.Closed)
    val scope = rememberCoroutineScope()

    val session by sessionViewModel.session.collectAsStateWithLifecycle()
    // What this shard publishes, independently of who the caller is (§5, M11).
    val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()

    // Re-validate the cached role each time the app returns to the foreground (§4.3).
    LifecycleResumeEffect(Unit) {
        sessionViewModel.revalidate()
        onPauseOrDispose { }
    }

    // A tapped push notification deep-links to its stream's screen (§11, item 7).
    LaunchedEffect(deepLinkStream) {
        val stream = deepLinkStream ?: return@LaunchedEffect
        navController.navigate(Routes.forStream(stream)) {
            popUpTo(Routes.HOME) { saveState = true }
            launchSingleTop = true
        }
        onDeepLinkConsumed()
    }

    val backStackEntry by navController.currentBackStackEntryAsState()
    // A destination's route is its NavHost *pattern*, so News reports
    // "news?category={category}" (§6.2). Compare on the part before the query.
    val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
    val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
    // The admin's nav overrides, then the gates — never the other way round. An
    // override is presentation only: it may relabel, reorder, group and hide, so
    // `pruneNav` still decides what this caller may see and remains the boundary
    // (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
    val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
        isEntryVisible(it, session, shardFeatures)
    }

    val context = LocalContext.current
    // An added link's path is site-relative; a hand-off needs it absolute against
    // the configured base URL, which is exactly what the asset resolver does (§6.3).
    val resolveUrl = LocalAssetResolver.current
    val openNode: (NavNode) -> Unit = { node ->
        scope.launch { drawerState.close() }
        when (node) {
            is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
            // A link the app resolved opens like any other drawer row, detail screen
            // or not: one rule, and back-press lands on Home as it does from every
            // row. One it could not resolve goes to the browser, absolute against
            // the site's base URL (§6.3).
            is NavNode.Link -> node.route
                ?.let { navController.navigateTopLevel(it) }
                ?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
            // Section headers aren't clickable — the group is always open (§6.3).
            is NavNode.Section -> Unit
        }
    }

    ModalNavigationDrawer(
        drawerState = drawerState,
        gesturesEnabled = isTopLevel,
        drawerContent = {
            ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) {
                val drawerItemColors = NavigationDrawerItemDefaults.colors(
                    selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
                    selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
                    unselectedTextColor = MaterialTheme.colorScheme.onSurface,
                )
                // Scroll the drawer: a signed-in session adds Account, Notifications, and
                // the player groups, and the full list overflows a phone's drawer height —
                // without this the lower entries (Notifications included) are clipped and
                // unreachable. See RunicGateway M10.
                Column(Modifier.verticalScroll(rememberScrollState())) {
                Spacer(Modifier.height(12.dp))
                // The instance's logo above its name (§5.6). Decorative — the name
                // is the very next line — and absent on an instance that uploaded
                // none, in which case the header is exactly what it was before M12.
                BrandLogo(
                    logo = brand?.logo,
                    height = 32.dp,
                    modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp),
                )
                Text(
                    text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
                    style = MaterialTheme.typography.titleLarge,
                    color = MaterialTheme.colorScheme.onSurface,
                    modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
                )
                HorizontalDivider()
                Spacer(Modifier.height(8.dp))
                nav.forEach { node ->
                    if (node is NavNode.Section) {
                        // A group the admin created: its label as a header, its rows
                        // beneath it. Always open — a drawer is already a vertical
                        // list, so the website's dropdown does not translate (§6.3).
                        Text(
                            text = node.label,
                            style = MaterialTheme.typography.labelLarge,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                            modifier = Modifier.padding(
                                start = 28.dp,
                                end = 28.dp,
                                top = 12.dp,
                                bottom = 4.dp,
                            ),
                        )
                        node.items.forEach { child ->
                            NavRow(child, currentRoute, drawerItemColors, indented = true) {
                                openNode(child)
                            }
                        }
                    } else {
                        NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
                    }
                }

                HorizontalDivider(Modifier.padding(vertical = 8.dp))

                // Sign in / Sign out toggles on the session (§5).
                val signInLabel = if (session is Session.SignedIn) {
                    R.string.menu_sign_out
                } else {
                    R.string.menu_sign_in
                }
                NavigationDrawerItem(
                    label = { Text(stringResource(signInLabel)) },
                    selected = false,
                    onClick = {
                        scope.launch { drawerState.close() }
                        if (session is Session.SignedIn) {
                            sessionViewModel.signOut()
                            navController.navigateTopLevel(Routes.HOME)
                        } else {
                            navController.navigate(Routes.LOGIN)
                        }
                    },
                    colors = drawerItemColors,
                    shape = LocalShardStructure.current.pill,
                    modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                )
                NavigationDrawerItem(
                    label = { Text(stringResource(R.string.menu_change_server)) },
                    selected = false,
                    onClick = {
                        scope.launch { drawerState.close() }
                        onChangeServer()
                    },
                    colors = drawerItemColors,
                    shape = LocalShardStructure.current.pill,
                    modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
                )
                }
            }
        },
    ) {
        Scaffold(
            modifier = modifier,
            topBar = {
                TopAppBar(
                    colors = TopAppBarDefaults.topAppBarColors(
                        containerColor = MaterialTheme.colorScheme.surfaceVariant,
                        titleContentColor = MaterialTheme.colorScheme.onSurface,
                        navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
                        actionIconContentColor = MaterialTheme.colorScheme.onSurface,
                    ),
                    title = {
                        val name = brand?.name?.takeIf { it.isNotBlank() }
                            ?: stringResource(R.string.app_name)
                        // The logo stands in for the title here, so unlike the drawer's
                        // it is named for a screen reader — and it falls back to the
                        // text when the instance has no logo or the load fails (§5.6).
                        BrandLogo(
                            logo = brand?.logo,
                            height = 24.dp,
                            contentDescription = name,
                        ) {
                            Text(
                                text = name.uppercase(),
                                style = MaterialTheme.typography.titleSmall
                                    .copy(letterSpacing = 1.2.sp),
                                maxLines = 1,
                                overflow = TextOverflow.Ellipsis,
                            )
                        }
                    },
                    navigationIcon = {
                        if (isTopLevel) {
                            IconButton(onClick = { scope.launch { drawerState.open() } }) {
                                Icon(Icons.Filled.Menu, stringResource(R.string.nav_open_menu))
                            }
                        } else {
                            IconButton(onClick = { navController.popBackStack() }) {
                                Icon(
                                    Icons.AutoMirrored.Filled.ArrowBack,
                                    stringResource(R.string.action_back),
                                )
                            }
                        }
                    },
                )
            },
        ) { innerPadding ->
            RunicNavHost(
                navController = navController,
                brand = brand,
                session = session,
                onSignOut = { sessionViewModel.signOut() },
                onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
                modifier = Modifier.padding(innerPadding),
            )
        }
    }
}

/**
 * One drawer row: a coded entry, or an admin's added link (§6.3).
 *
 * A link that the app can open natively is deliberately indistinguishable from a
 * coded row — that is the point of resolving it. One that hands off to the browser
 * carries a trailing icon, so leaving the app is never a surprise.
 */
@Composable
private fun NavRow(
    node: NavNode,
    currentRoute: String?,
    colors: NavigationDrawerItemColors,
    indented: Boolean = false,
    onClick: () -> Unit,
) {
    val route = when (node) {
        is NavNode.Item -> node.entry.route
        is NavNode.Link -> node.route
        is NavNode.Section -> null
    }
    val label = when (node) {
        // An admin's label wins over the bundled one, and is the same string in
        // every locale — see MenuEntry.label.
        is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
        is NavNode.Link -> node.label
        is NavNode.Section -> return
    }
    val handsOff = node is NavNode.Link && node.route == null

    NavigationDrawerItem(
        label = { Text(label) },
        selected = route != null && currentRoute == route.substringBefore('?'),
        onClick = onClick,
        badge = if (!handsOff) {
            null
        } else {
            {
                Icon(
                    Icons.AutoMirrored.Filled.ExitToApp,
                    contentDescription = stringResource(R.string.nav_opens_in_browser),
                    modifier = Modifier.size(18.dp),
                )
            }
        },
        colors = colors,
        // Like Card's elevation, NavigationDrawerItem takes its shape as a default
        // argument (CircleShape) rather than from the theme, so --radius-pill has to
        // be handed to it at every call site or the selected row stays fully round
        // while every other radius follows the shard (phase 8's AC-5 walk).
        shape = LocalShardStructure.current.pill,
        modifier = Modifier
            .padding(NavigationDrawerItemDefaults.ItemPadding)
            .padding(start = if (indented) 16.dp else 0.dp),
    )
}

@Composable
private fun RunicNavHost(
    navController: NavHostController,
    brand: BrandDto?,
    session: Session,
    onSignOut: () -> Unit,
    onSignOutEverywhere: () -> Unit,
    modifier: Modifier = Modifier,
) {
    NavHost(
        navController = navController,
        startDestination = Routes.HOME,
        modifier = modifier,
    ) {
        composable(Routes.HOME) {
            HomeScreen(brand = brand)
        }
        // The category is optional: navigating to plain Routes.NEWS matches this
        // pattern with no argument and opens the default tab, which is every route
        // into the screen except an admin's nav override or added link (§6.2).
        composable(
            route = Routes.NEWS_ROUTE,
            arguments = listOf(
                navArgument(Routes.Args.CATEGORY) {
                    type = NavType.StringType
                    nullable = true
                    defaultValue = null
                },
            ),
        ) {
            NewsScreen(onOpenPost = { category, idOrSlug ->
                navController.navigate(Routes.post(category, idOrSlug))
            })
        }
        composable(
            route = Routes.POST,
            arguments = listOf(
                navArgument(Routes.Args.CATEGORY) { type = NavType.StringType },
                navArgument(Routes.Args.ID_OR_SLUG) { type = NavType.StringType },
            ),
        ) {
            PostScreen()
        }
        composable(Routes.SHARD) {
            ShardScreen(onOpenBoard = { board ->
                navController.navigate(
                    when (board) {
                        ShardBoard.CHAMPS -> Routes.SHARD_CHAMPS
                        ShardBoard.GUILDS -> Routes.SHARD_GUILDS
                        ShardBoard.GOVERNORS -> Routes.SHARD_GOVERNORS
                        ShardBoard.HOUSES -> Routes.SHARD_HOUSES
                    },
                )
            })
        }
        composable(Routes.SHARD_CHAMPS) { ChampsScreen() }
        composable(Routes.SHARD_GUILDS) { GuildsScreen() }
        composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
        composable(Routes.SHARD_HOUSES) { HousesScreen() }

        // Protocol 3.0 shard content (M11). Each screen self-reports "not published
        // here" from its own 404/403, so a deep link to a gated feature still lands on
        // an honest answer even though the menu hides the entry.
        composable(Routes.SHARD_RULES) { RulesScreen() }
        composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) }
        composable(Routes.SHARD_MARKET) {
            MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) })
        }
        composable(
            route = Routes.SHARD_MARKET_VENDOR,
            arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
        ) { entry ->
            MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty())
        }
        composable(Routes.ATLAS) {
            AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) })
        }
        composable(
            route = Routes.ATLAS_CREATURE,
            arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
        ) { entry ->
            AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
        }
        composable(Routes.WIKI) {
            WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
        }
        composable(
            route = Routes.WIKI_PAGE,
            arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
        ) {
            WikiPageScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
        }
        composable(
            route = Routes.PAGE,
            arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
        ) {
            PageScreen()
        }
        composable(Routes.CONTACT) {
            ContactScreen()
        }
        composable(Routes.LOGIN) {
            // Leave the login screen as soon as the session is established — whether by
            // password or the SSO bridge. Keying off the shared session (not just the
            // login VM's local flag) makes this robust to the deep-link/recomposition
            // timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
            if (session is Session.SignedIn) {
                LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
            } else {
                LoginScreen(onSignedIn = { navController.popBackStack() })
            }
        }
        composable(Routes.ACCOUNT) {
            // Only meaningful while signed in; a sign-out (here or from the drawer)
            // sends the user home rather than leaving a stale identity on screen.
            when (val s = session) {
                is Session.SignedIn -> AccountScreen(
                    username = s.user.username,
                    roleLabel = stringResource(roleLabelRes(s.user.role)),
                    onSignOut = onSignOut,
                    onSignOutEverywhere = onSignOutEverywhere,
                    onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
                    onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
                )
                Session.SignedOut -> LaunchedEffect(Unit) {
                    navController.navigateTopLevel(Routes.HOME)
                }
            }
        }
        composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
            // Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
            when (session) {
                is Session.SignedIn -> TrustedDevicesScreen()
                Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
            }
        }
        composable(Routes.ACCOUNT_RECOVERY_CODES) {
            when (session) {
                is Session.SignedIn -> RecoveryCodesScreen()
                Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
            }
        }
        composable(Routes.NOTIFICATIONS) {
            // Signed-in only; a sign-out (or demotion) sends the user home rather than
            // leaving stale settings up. The backend gates every call regardless (§5).
            when (session) {
                is Session.SignedIn -> NotificationsScreen()
                Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
            }
        }

        // ── Player game data (§6.3) — reached from the player-only menu groups.
        // The server enforces the player gate on every call; these screens simply
        // render 401/403/503 as clean states (§7).
        composable(Routes.PLAYER_CHARACTERS) {
            PlayerGate(session, navController) {
                CharactersScreen(onOpenChar = { serial -> navController.navigate(Routes.playerChar(serial)) })
            }
        }
        composable(
            route = Routes.PLAYER_CHAR,
            arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
        ) {
            CharacterSheetScreen()
        }
        composable(Routes.PLAYER_VENDORS) {
            PlayerGate(session, navController) { VendorsScreen() }
        }
        composable(Routes.PLAYER_HOUSES) {
            PlayerGate(session, navController) { MyHousesScreen() }
        }

        // ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
        // The backend re-checks role on every /admin/… call; these gates only mirror
        // the menu's visibility so a signed-out/demoted user isn't left on a stale screen.
        composable(Routes.ADMIN_DASHBOARD) {
            StaffGate(session, navController) {
                AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true)
            }
        }
        composable(Routes.ADMIN_CONTENT) {
            StaffGate(session, navController) { AdminContentScreen() }
        }
        composable(Routes.ADMIN_MODERATION) {
            StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() }
        }
        composable(Routes.ADMIN_SUPPORT) {
            StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() }
        }
    }
}

/**
 * A UX guard for the player-only groups: while signed in, render [content]; if the
 * session drops (sign-out, or a server-side demotion caught on resume, §4.3), send
 * the user home instead of leaving a stale player screen up. The backend remains
 * the authority — this only mirrors the menu's visibility rule.
 */
@Composable
private fun PlayerGate(
    session: Session,
    navController: NavHostController,
    content: @Composable () -> Unit,
) {
    when (session) {
        is Session.SignedIn -> content()
        Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
    }
}

/**
 * The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for
 * a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is
 * sent home rather than left on a stale admin screen. The backend is the authority —
 * every `/admin/…` call re-checks role — so this only mirrors the menu's visibility.
 */
@Composable
private fun StaffGate(
    session: Session,
    navController: NavHostController,
    require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff },
    content: @Composable () -> Unit,
) {
    val ok = (session as? Session.SignedIn)?.user?.let(require) == true
    if (ok) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}

/** Navigate to a top-level menu destination: single instance, reset to it. */
private fun NavHostController.navigateTopLevel(route: String) {
    navigate(route) {
        popUpTo(Routes.HOME) { saveState = true }
        launchSingleTop = true
        restoreState = true
    }
}
