/*
 * Copyright (C) 2026 Memby contributors
 *
 * SPDX-License-Identifier: GPL-2.0-only
 */

@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)

package com.ponzischeme89.memby.ui.settings

import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Storage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.ImageCacheMaintenance
import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.data.formatCacheSize
import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout

internal data class ChoiceOption(val value: String, val label: String, val color: Color? = null)

private val RingOptions = listOf(
    ChoiceOption("FFFFFF", "White", Color.White),
    ChoiceOption("52B54B", "Memby green", Color(0xFF52B54B)),
    ChoiceOption("E50914", "Cinema red", Color(0xFFE50914)),
)

private val DensityOptions = listOf(
    ChoiceOption("compact", "Compact"),
    ChoiceOption("standard", "Standard"),
    ChoiceOption("large", "Large"),
)

// Values are the seconds themselves, as text, so the chip row and the stored integer
// cannot drift: the vocabulary is SEEK_INTERVAL_SECONDS and nothing else names it twice.
private val SeekIntervalOptions = SEEK_INTERVAL_SECONDS.map {
    ChoiceOption(it.toString(), "$it seconds")
}

// The wording is what a viewer chooses between, not the mode names the wire carries.
private val SkipIntroOptions = listOf(
    ChoiceOption(SKIP_INTRO_PROMPT, "Ask me"),
    ChoiceOption(SKIP_INTRO_AUTO, "Skip it"),
    ChoiceOption(SKIP_INTRO_OFF, "Leave it"),
)

private val ArtworkOptions = listOf(
    ChoiceOption("automatic", "Automatic"),
    ChoiceOption("poster", "Posters"),
    ChoiceOption("backdrop", "Backdrops"),
)

private val WelcomeOptions = WelcomeQuoteStyle.entries.map {
    ChoiceOption(it.value, it.label)
}

internal enum class SettingsPage(
    val label: String,
    val description: String,
    val icon: ImageVector,
    val showInRail: Boolean = true,
) {
    APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette),
    PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow),
    HOME("Home screen", "What you see when Memby opens", Icons.Default.Home),
    // No Updates page. The manual check needs a Gitea address, a repository and a token,
    // and nothing on this television can enter them — so the button could only ever report
    // a failure, on the one screen a viewer goes to when they suspect something is wrong.
    // Updates arrive through the gateway's own verdict (ui/UpdateScreen.kt), which carries
    // its download URL with it.
    DEVICES("Devices", "TVs signed in to your account", Icons.Default.Devices),
    STORAGE("Storage", "Artwork Memby keeps on this TV", Icons.Default.Storage),
    ABOUT("About", "Version and release notes", Icons.Default.Info),
}

// Black, and one lit thing at a time.
//
// Settings used to stack four surfaces to show two switches: the page, the rail, a titled
// section card, and the rows inside it. On a television that reads as grey boxes on grey
// boxes, and none of it carried information — the rail already says which page you are on
// and the header already names it. So the canvas and the rail are black, the section card
// is gone, and rows sit flat on it separated by a hairline. The only surface that lights up
// is the row under focus, which is the one thing a viewer actually needs to find.
//
// The controls are deliberately untouched: the green pill toggle and the chip row are what
// make this screen feel like Memby, and they read better against black than they did
// against a card.
// The one colour on this page that is not fixed. It is the accent, and the accent is the
// viewer's own choice now — a picker offering an orange scheme with a green selected chip
// would be showing them the wrong answer to the question they are being asked.
private val EmbyGreen: Color get() = MembyAccent
private val Canvas = Color(0xFF000000)
private val Panel = Color(0xFF040506)
private val RowFocused = Color(0xFF1B2228)
private val ControlIdle = Color(0xFF1E252B)
private val TextPrimary = Color(0xFFF2F5F7)
private val TextSecondary = Color(0xFFAFB8BF)
private val TextQuiet = Color(0xFF7C868E)
private val Hairline = Color.White.copy(alpha = 0.08f)
private val RailEdge = Color.White.copy(alpha = 0.10f)

internal data class SettingsPanelState(
    val showLogo: Boolean = true,
    val autoPlayNext: Boolean = true,
    val showTenMinuteReminder: Boolean = true,
    val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
    val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
    val speedUpCredits: Boolean = true,
    val ringColor: String = "52B54B",
    val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
    val cardDensity: String = "standard",
    val artworkStyle: String = "automatic",
    val hiddenHomeRowCount: Int = 0,
    val showCardMetadata: Boolean = true,
    val showRatingsStrip: Boolean = true,
    val hideWatchedMovies: Boolean = false,
    val confirmExitMemby: Boolean = false,
    val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
    /**
     * The viewer's own colour scheme, and the ones the gateway says they may pick between.
     *
     * [themeOptions] is empty on the direct path, on a gateway that predates themes, and
     * before the first fetch — in every one of which the row is simply not drawn. Nothing
     * here falls back to a catalogue compiled into the app: a scheme the operator has
     * withheld has to be one this television was never sent, or the allowlist is decoration.
     */
    val themeId: String = Settings.DEFAULT_THEME_ID,
    val themeOptions: List<ChoiceOption> = emptyList(),
    /**
     * A season is on, so the chips are shown but cannot be moved and [themeNotice] says why.
     * The viewer's own choice stays selected underneath, because it is still theirs and
     * comes back when the season ends.
     */
    val themeLocked: Boolean = false,
    /** The gateway's wording for the lock, so a season invented later still reads right. */
    val themeNotice: String = "",
    val selectedPage: SettingsPage = SettingsPage.APPEARANCE,
    val installedVersion: String = "",
    val gatewayVersion: String = "",
    val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
    val devices: List<GatewayDevice> = emptyList(),
    val devicesLoading: Boolean = false,
    val devicesError: String? = null,
    val removingDeviceId: String? = null,
    val pendingRemovalDeviceId: String? = null,
    /**
     * How much artwork this TV is holding, or null while it is still being measured.
     *
     * Null rather than [ImageCacheSize.EMPTY] on purpose: reading the disk cache's size
     * walks its journal, so there is a moment before the answer arrives, and a page that
     * showed "0 MB" during it would be telling the viewer the opposite of the truth.
     */
    val imageCacheSize: ImageCacheSize? = null,
    val imageCacheClearing: Boolean = false,
    val imageCacheCleared: Boolean = false,
)

internal data class SettingsPanelActions(
    val onClose: () -> Unit = {},
    val onShowLogoChanged: (Boolean) -> Unit = {},
    val onAutoPlayNextChanged: (Boolean) -> Unit = {},
    val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
    val onSeekIntervalChanged: (Int) -> Unit = {},
    val onSkipIntroModeChanged: (String) -> Unit = {},
    val onSpeedUpCreditsChanged: (Boolean) -> Unit = {},
    val onRingColorChanged: (String) -> Unit = {},
    val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
    val onCardDensityChanged: (String) -> Unit = {},
    val onArtworkStyleChanged: (String) -> Unit = {},
    val onRestoreHiddenRows: () -> Unit = {},
    val onShowCardMetadataChanged: (Boolean) -> Unit = {},
    val onShowRatingsStripChanged: (Boolean) -> Unit = {},
    val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
    val onConfirmExitMembyChanged: (Boolean) -> Unit = {},
    val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
    val onThemeChanged: (String) -> Unit = {},
    val onPageSelected: (SettingsPage) -> Unit = {},
    val onRefreshDevices: () -> Unit = {},
    val onRenameDevice: (GatewayDevice) -> Unit = {},
    val onRemoveDevice: (GatewayDevice) -> Unit = {},
    val onCancelDeviceRemoval: () -> Unit = {},
    val onClearImageCache: () -> Unit = {},
)

/**
 * Settings is shared by the launcher and Dream overlay. State remains in the shared
 * SettingsStore; [SettingsPanelContent] is deliberately stateless so the exact running
 * UI can also be rendered in TV previews and screenshot tests.
 */
@Composable
fun SettingsSheet(
    onClose: () -> Unit,
    modifier: Modifier = Modifier,
    overlay: Boolean = false,
    navigationFocusRequester: FocusRequester? = null,
) {
    val context = LocalContext.current
    val store = ServiceLocator.settings
    val scope = rememberCoroutineScope()
    // Kept only for the version this TV is running, which About prints. Nothing here checks
    // for an update: see the note on SettingsPage.
    val checker = remember { UpdateChecker(context) }
    // Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects
    // default chips before DataStore emits; a viewer could press "Automatic" in that gap
    // and then watch the stored "Posters" value appear to revert their choice.
    val settings by ServiceLocator.repository.settingsFlow.collectAsState(
        initial = ServiceLocator.repository.currentSettings,
    )

    var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
    var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
    var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
    var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
    var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
    var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) }
    var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
    var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
    var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
    var artworkStyle by rememberSaveable { mutableStateOf(settings.homeArtworkStyle) }
    var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) }
    var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
    var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
    var confirmExitMemby by rememberSaveable { mutableStateOf(settings.confirmExitMemby) }
    var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
    // The resolved theme and the schemes on offer both come from the gateway, through the
    // sync that owns them. Collected here rather than in SettingsPanelContent so the content
    // stays parameter-driven and screenshot-testable with no server.
    val resolvedTheme by ServiceLocator.themeSync.theme.collectAsState()
    val availableThemes by ServiceLocator.themeSync.available.collectAsState()
    val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsState()
    var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) }
    var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
    var devicesLoading by remember { mutableStateOf(false) }
    var devicesError by remember { mutableStateOf<String?>(null) }
    var removingDeviceId by remember { mutableStateOf<String?>(null) }
    var pendingRemovalDeviceId by remember { mutableStateOf<String?>(null) }
    var editingDevice by remember { mutableStateOf<GatewayDevice?>(null) }
    var deviceJob by remember { mutableStateOf<Job?>(null) }
    var imageCacheSize by remember { mutableStateOf<ImageCacheSize?>(null) }
    var imageCacheClearing by remember { mutableStateOf(false) }
    var imageCacheCleared by remember { mutableStateOf(false) }

    suspend fun refreshDevices() {
        devicesLoading = true
        devicesError = null
        try {
            devices = withTimeout(8_000L) { ServiceLocator.repository.devices() }
        } catch (cancelled: CancellationException) {
            throw cancelled
        } catch (_: Throwable) {
            devicesError = "Couldn’t load signed-in devices. You can continue using Settings."
        } finally {
            devicesLoading = false
        }
    }

    LaunchedEffect(selectedPage, settings.token) {
        deviceJob?.cancel()
        pendingRemovalDeviceId = null
        if (selectedPage == SettingsPage.DEVICES && settings.isSignedIn) {
            refreshDevices()
        } else {
            editingDevice = null
            removingDeviceId = null
        }
    }

    // Measured when the page is opened rather than when Settings is, because walking the
    // disk cache's journal has no business happening on a viewer who came here to turn
    // subtitles on. It is re-measured on every arrival, since a browse between two visits
    // will have filled it again.
    LaunchedEffect(selectedPage) {
        if (selectedPage == SettingsPage.STORAGE && !imageCacheClearing) {
            imageCacheSize = ImageCacheMaintenance.measure(context)
        }
    }

    LaunchedEffect(
        settings.showTitleLogo,
        settings.ringColorHex,
        settings.homeSections,
        settings.homeCardDensity,
        settings.homeArtworkStyle,
        settings.showHomeCardMetadata,
        settings.showRatingsStrip,
        settings.hideWatchedMovies,
        settings.confirmExitMemby,
        settings.autoPlayNextEpisode,
        settings.showTenMinuteReminder,
        settings.seekIntervalSeconds,
        settings.skipIntroMode,
        settings.speedUpCredits,
        settings.welcomeQuoteStyle,
    ) {
        showLogo = settings.showTitleLogo
        autoPlayNext = settings.autoPlayNextEpisode
        showTenMinuteReminder = settings.showTenMinuteReminder
        seekInterval = settings.seekIntervalSeconds
        skipIntroMode = settings.skipIntroMode
        speedUpCredits = settings.speedUpCredits
        ringColor = settings.ringColorHex
        homeSections = settings.homeSections.split(',').toSet()
        cardDensity = settings.homeCardDensity
        artworkStyle = settings.homeArtworkStyle
        showCardMetadata = settings.showHomeCardMetadata
        showRatingsStrip = settings.showRatingsStrip
        hideWatchedMovies = settings.hideWatchedMovies
        confirmExitMemby = settings.confirmExitMemby
        welcomeQuoteStyle = settings.welcomeQuoteStyle
    }

    val firstFocus = remember { FocusRequester() }
    var shown by remember { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        shown = true
        delay(170)
        runCatching { firstFocus.requestFocus() }
    }

    val state = SettingsPanelState(
        showLogo = showLogo,
        autoPlayNext = autoPlayNext,
        showTenMinuteReminder = showTenMinuteReminder,
        seekIntervalSeconds = seekInterval,
        skipIntroMode = skipIntroMode,
        speedUpCredits = speedUpCredits,
        ringColor = ringColor,
        homeSections = homeSections,
        cardDensity = cardDensity,
        artworkStyle = artworkStyle,
        hiddenHomeRowCount = settings.homeHiddenRows.lineSequence().count { it.isNotBlank() },
        showCardMetadata = showCardMetadata,
        showRatingsStrip = showRatingsStrip,
        hideWatchedMovies = hideWatchedMovies,
        confirmExitMemby = confirmExitMemby,
        welcomeQuoteStyle = welcomeQuoteStyle,
        themeId = settings.themeId,
        themeOptions = availableThemes.map { theme ->
            ChoiceOption(theme.id, theme.name, parseThemeColor(theme.palette.accent))
        },
        // Locked is taken from the server's answer and defaults to false: a set that has not
        // been told anything must not present a picker it will not let anybody use.
        themeLocked = resolvedTheme?.locked == true,
        themeNotice = resolvedTheme?.takeIf { it.locked }?.reason.orEmpty(),
        selectedPage = selectedPage,
        installedVersion = checker.installedVersion,
        gatewayVersion = gatewayVersion,
        devices = devices,
        devicesLoading = devicesLoading,
        devicesError = devicesError,
        removingDeviceId = removingDeviceId,
        pendingRemovalDeviceId = pendingRemovalDeviceId,
        imageCacheSize = imageCacheSize,
        imageCacheClearing = imageCacheClearing,
        imageCacheCleared = imageCacheCleared,
    )
    val actions = SettingsPanelActions(
        onClose = onClose,
        onShowLogoChanged = {
            showLogo = it
            scope.launch { store.setShowTitleLogo(it) }
        },
        onAutoPlayNextChanged = {
            autoPlayNext = it
            scope.launch { store.setAutoPlayNextEpisode(it) }
        },
        onShowTenMinuteReminderChanged = {
            showTenMinuteReminder = it
            scope.launch { store.setShowTenMinuteReminder(it) }
        },
        onSeekIntervalChanged = {
            seekInterval = it
            scope.launch { store.setSeekIntervalSeconds(it) }
        },
        onSkipIntroModeChanged = {
            skipIntroMode = it
            scope.launch { store.setSkipIntroMode(it) }
        },
        onSpeedUpCreditsChanged = {
            speedUpCredits = it
            scope.launch { store.setSpeedUpCredits(it) }
        },
        onRingColorChanged = {
            ringColor = it
            scope.launch { store.setRingColor(it) }
        },
        onHomeSectionChanged = { key, enabled ->
            homeSections = if (enabled) homeSections + key else homeSections - key
            scope.launch { store.setHomeSections(homeSections.toList()) }
        },
        onCardDensityChanged = {
            cardDensity = it
            scope.launch { store.setHomeCardDensity(it) }
        },
        onArtworkStyleChanged = {
            artworkStyle = it
            scope.launch {
                // Back closes this composable and cancels its scope. Once a selection has
                // been accepted on screen, its tiny atomic DataStore write must finish even
                // if the viewer leaves Settings immediately afterwards.
                withContext(NonCancellable) { store.setHomeArtworkStyle(it) }
            }
        },
        onRestoreHiddenRows = {
            scope.launch {
                store.setHomeRowPreferences(
                    settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(),
                    settings.homePinnedRows.lineSequence().filter { it.isNotBlank() }.toSet(),
                    emptySet(),
                )
            }
        },
        onShowCardMetadataChanged = {
            showCardMetadata = it
            scope.launch { store.setShowHomeCardMetadata(it) }
        },
        onShowRatingsStripChanged = {
            showRatingsStrip = it
            scope.launch { store.setShowRatingsStrip(it) }
        },
        onHideWatchedMoviesChanged = {
            hideWatchedMovies = it
            scope.launch { store.setHideWatchedMovies(it) }
        },
        onConfirmExitMembyChanged = {
            confirmExitMemby = it
            scope.launch { store.setConfirmExitMemby(it) }
        },
        onThemeChanged = { chosen ->
            // No local echo: what is on screen is the palette the gateway resolves, and it
            // arrives through ThemeSync a moment later. Painting optimistically here would
            // show a viewer a scheme that a season, or an allowlist they do not know about,
            // is about to take back off them.
            scope.launch { store.setThemeId(chosen) }
        },
        onWelcomeQuoteStyleChanged = {
            welcomeQuoteStyle = it
            scope.launch { store.setWelcomeQuoteStyle(it) }
        },
        onPageSelected = { selectedPage = it },
        onRefreshDevices = {
            deviceJob?.cancel()
            deviceJob = scope.launch { refreshDevices() }
        },
        onRenameDevice = {
            devicesError = null
            editingDevice = it
            pendingRemovalDeviceId = null
        },
        onRemoveDevice = removeDevice@{ device ->
            if (device.current || removingDeviceId != null) return@removeDevice
            if (pendingRemovalDeviceId != device.deviceId) {
                pendingRemovalDeviceId = device.deviceId
            } else {
                removingDeviceId = device.deviceId
                devicesError = null
                deviceJob?.cancel()
                deviceJob = scope.launch {
                    try {
                        withTimeout(8_000L) { ServiceLocator.repository.removeDevice(device.deviceId) }
                        devices = devices.filterNot { it.deviceId == device.deviceId }
                        pendingRemovalDeviceId = null
                    } catch (cancelled: CancellationException) {
                        throw cancelled
                    } catch (_: Throwable) {
                        devicesError = "Couldn’t remove ${device.deviceName}."
                    } finally {
                        removingDeviceId = null
                    }
                }
            }
        },
        onCancelDeviceRemoval = { pendingRemovalDeviceId = null },
        onClearImageCache = clearCache@{
            if (imageCacheClearing) return@clearCache
            imageCacheClearing = true
            imageCacheCleared = false
            scope.launch {
                // NonCancellable because emptying 128MB of files outlives a viewer pressing
                // Back, and a half-cleared cache is exactly the state this button exists to
                // get out of. The state written afterwards is the composition's, so a screen
                // that has gone simply drops it.
                val remaining = withContext(NonCancellable) {
                    runCatching { ImageCacheMaintenance.clear(context) }
                        .getOrDefault(ImageCacheSize.EMPTY)
                }
                imageCacheSize = remaining
                imageCacheCleared = true
                imageCacheClearing = false
            }
        },
    )

    Box(modifier = modifier.fillMaxSize()) {
        if (overlay) {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(Color(0xB3000000))
                    .clickable(
                        interactionSource = remember {
                            androidx.compose.foundation.interaction.MutableInteractionSource()
                        },
                        indication = null,
                        onClick = onClose,
                    ),
            )
        } else {
            Box(Modifier.fillMaxSize().background(Canvas))
        }

        AnimatedVisibility(
            visible = shown,
            enter = if (overlay) {
                slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150))
            } else {
                fadeIn(tween(150))
            },
            modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center),
        ) {
            SettingsPanelContent(
                state = state,
                actions = actions,
                overlay = overlay,
                firstFocusRequester = firstFocus,
                navigationFocusRequester = navigationFocusRequester,
            )
        }
        editingDevice?.let { device ->
            DeviceRenameDialog(
                device = device,
                busy = deviceJob?.isActive == true,
                error = devicesError,
                onCancel = { editingDevice = null },
                onSave = { name ->
                    deviceJob?.cancel()
                    devicesError = null
                    deviceJob = scope.launch {
                        try {
                            withTimeout(8_000L) { ServiceLocator.repository.renameDevice(device, name) }
                            devices = devices.map {
                                if (it.deviceId == device.deviceId) it.copy(deviceName = name.trim()) else it
                            }
                            editingDevice = null
                        } catch (cancelled: CancellationException) {
                            throw cancelled
                        } catch (_: Throwable) {
                            devicesError = "Couldn’t rename ${device.deviceName}."
                        }
                    }
                },
            )
        }
    }
}

@Composable
internal fun SettingsPanelContent(
    state: SettingsPanelState,
    actions: SettingsPanelActions,
    overlay: Boolean,
    modifier: Modifier = Modifier,
    firstFocusRequester: FocusRequester? = null,
    navigationFocusRequester: FocusRequester? = null,
) {
    val contentScrollState = rememberScrollState()
    // Right out of the rail was the one direction on this screen left to Compose's spatial
    // search, and it is the one that has to cross from a fixed column into a pane whose
    // first control is somewhere different on every page — a chip row high up on Welcome, a
    // full-width row far down on Devices. A search that finds nothing is a press that does
    // nothing, which is what "stuck on that page" looks like from the sofa. The pane is a
    // focus group, so one requester names "whatever this page starts with".
    val contentFocusRequester = remember { FocusRequester() }

    LaunchedEffect(state.selectedPage) {
        contentScrollState.scrollTo(0)
    }

    Row(
        modifier = modifier
            .then(if (overlay) Modifier.width(760.dp) else Modifier.fillMaxWidth())
            .fillMaxHeight()
            .background(if (overlay) Panel else Canvas)
            .then(
                if (overlay) {
                    Modifier.border(1.dp, Color.White.copy(alpha = 0.10f))
                } else {
                    Modifier
                },
            ),
    ) {
        SettingsSecondaryRail(
            selected = state.selectedPage,
            onSelected = actions.onPageSelected,
            onClose = actions.onClose,
            firstFocusRequester = firstFocusRequester,
            navigationFocusRequester = navigationFocusRequester,
            contentFocusRequester = contentFocusRequester,
            compact = overlay,
            modifier = Modifier
                .width(if (overlay) 190.dp else 224.dp)
                .fillMaxHeight(),
        )
        Column(
            modifier = Modifier
                .weight(1f)
                .fillMaxHeight()
                // The requester has to sit above the focus target it names, and focusGroup
                // is a target that cannot hold focus itself — so a request on it enters the
                // page's first control, whatever that page turns out to be.
                .focusRequester(contentFocusRequester)
                .focusGroup()
                .verticalScroll(contentScrollState)
                .padding(
                    start = if (overlay) 26.dp else 42.dp,
                    end = if (overlay) 28.dp else 64.dp,
                    top = if (overlay) 28.dp else 38.dp,
                    bottom = 38.dp,
                ),
            verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
        ) {
            SettingsHeader(page = state.selectedPage)
            when (state.selectedPage) {
                SettingsPage.APPEARANCE -> SettingsGroup {
                    // Only drawn when the server has offered something to choose from. On
                    // the direct path, on an older gateway, and for a viewer the operator
                    // has left with one scheme, there is no question to ask — and a row of
                    // one chip that cannot be moved is worse than no row.
                    if (state.themeOptions.size > 1) {
                        SettingsColourSchemeRow(
                            options = state.themeOptions,
                            selected = state.themeId,
                            locked = state.themeLocked,
                            notice = state.themeNotice,
                            onSelected = actions.onThemeChanged,
                        )
                        SettingDivider()
                    }
                    SettingsToggleRow(
                        title = "Show logos",
                        description = "Use a film or show's own logo instead of plain text.",
                        checked = state.showLogo,
                        onCheckedChange = actions.onShowLogoChanged,
                    )
                    SettingDivider()
                    SettingsChoiceRow(
                        title = "Progress colour",
                        description = "The colour of progress bars and countdowns.",
                        options = RingOptions,
                        selected = state.ringColor,
                        onSelected = actions.onRingColorChanged,
                    )
                    SettingDivider()
                    // The welcome line had a rail page to itself for one chip row. A page
                    // holding a single question is a destination a viewer has to find before
                    // they can answer it, and the question — what Memby sounds like when it
                    // opens — is the same one the rest of this page is asking.
                    SettingsChoiceRow(
                        title = "Welcome tone",
                        description = "A different line is picked each time Memby opens.",
                        options = WelcomeOptions,
                        selected = state.welcomeQuoteStyle,
                        onSelected = actions.onWelcomeQuoteStyleChanged,
                    )
                    SettingsNotice(
                        text = when (WelcomeQuoteStyle.from(state.welcomeQuoteStyle)) {
                            WelcomeQuoteStyle.NEUTRAL -> "“The sofa has been expecting you.”"
                            WelcomeQuoteStyle.POSITIVE -> "“Tonight has strong main-character energy.”"
                            WelcomeQuoteStyle.HOMICIDAL -> "“The remote knows what it did.”"
                        },
                        positive = true,
                    )
                }
                SettingsPage.PLAYBACK -> SettingsGroup {
                    SettingsToggleRow(
                        title = "Ten minutes left",
                        description = "A small reminder near the end of what you're watching.",
                        checked = state.showTenMinuteReminder,
                        onCheckedChange = actions.onShowTenMinuteReminderChanged,
                    )
                    SettingDivider()
                    SettingsToggleRow(
                        title = "Play the next episode",
                        description = "Roll straight into the next one when an episode ends.",
                        checked = state.autoPlayNext,
                        onCheckedChange = actions.onAutoPlayNextChanged,
                    )
                    SettingDivider()
                    SettingsChoiceRow(
                        title = "Opening titles",
                        description = "What to do when an episode reaches its intro.",
                        options = SkipIntroOptions,
                        selected = state.skipIntroMode,
                        onSelected = actions.onSkipIntroModeChanged,
                    )
                    SettingDivider()
                    SettingsToggleRow(
                        title = "Closing credits",
                        description = "Shrink them to one side at double speed and show " +
                            "what is on next.",
                        checked = state.speedUpCredits,
                        onCheckedChange = actions.onSpeedUpCreditsChanged,
                    )
                    SettingDivider()
                    SettingsChoiceRow(
                        title = "Skip with left and right",
                        description = "How far one press moves what you are watching.",
                        options = SeekIntervalOptions,
                        selected = state.seekIntervalSeconds.toString(),
                        onSelected = { value ->
                            value.toIntOrNull()?.let(actions.onSeekIntervalChanged)
                        },
                    )
                }
                SettingsPage.HOME -> SettingsGroup {
                    listOf(
                        "continue" to (
                            "Continue watching" to
                                "Pick up where you left off, and the next episode when you finish one."
                            ),
                        "favorites" to ("Favourites" to "Films and shows you have starred."),
                        "latest" to ("Latest movies" to "Films added to the library recently."),
                    ).forEachIndexed { index, (key, copy) ->
                        if (index > 0) SettingDivider()
                        SettingsToggleRow(
                            title = copy.first,
                            description = copy.second,
                            checked = key in state.homeSections,
                            onCheckedChange = { actions.onHomeSectionChanged(key, it) },
                        )
                    }
                    SettingDivider()
                    SettingsToggleRow(
                        title = "Hide films you have seen",
                        description = "Keep finished films out of the rows on Home.",
                        checked = state.hideWatchedMovies,
                        onCheckedChange = actions.onHideWatchedMoviesChanged,
                    )
                    SettingDivider()
                    SettingsChoiceRow(
                        title = "Card size",
                        description = "How big the artwork is on each row.",
                        options = DensityOptions,
                        selected = state.cardDensity,
                        onSelected = actions.onCardDensityChanged,
                    )
                    SettingDivider()
                    SettingsChoiceRow(
                        title = "Card shape",
                        description = "Tall posters, wide images, or let Memby pick.",
                        options = ArtworkOptions,
                        selected = state.artworkStyle,
                        onSelected = actions.onArtworkStyleChanged,
                    )
                    SettingDivider()
                    SettingsToggleRow(
                        title = "Text under cards",
                        description = "Show the year, length and episode under each card.",
                        checked = state.showCardMetadata,
                        onCheckedChange = actions.onShowCardMetadataChanged,
                    )
                    SettingDivider()
                    SettingsToggleRow(
                        title = "Ratings",
                        description = "Show IMDb and Rotten Tomatoes scores.",
                        checked = state.showRatingsStrip,
                        onCheckedChange = actions.onShowRatingsStripChanged,
                    )
                    SettingDivider()
                    SettingsToggleRow(
                        title = "Confirm before closing Memby",
                        description = "Ask before the Back button closes the app.",
                        checked = state.confirmExitMemby,
                        onCheckedChange = actions.onConfirmExitMembyChanged,
                    )
                    if (state.hiddenHomeRowCount > 0) {
                        SettingDivider()
                        SettingsActionRow(
                            title = "Bring back hidden rows",
                            description = "Show every row on Home again.",
                            badge = "${state.hiddenHomeRowCount} HIDDEN",
                            onClick = actions.onRestoreHiddenRows,
                        )
                    }
                }
                SettingsPage.DEVICES -> SettingsGroup {
                    when {
                        state.devicesLoading && state.devices.isEmpty() ->
                            SettingsNotice("Looking for signed-in TVs…", positive = true)
                        state.devices.isEmpty() ->
                            SettingsNotice("No other TVs are signed in.", positive = false)
                        else -> state.devices.forEachIndexed { index, device ->
                            if (index > 0) SettingDivider()
                            DeviceManagementRow(
                                device = device,
                                pendingRemoval = state.pendingRemovalDeviceId == device.deviceId,
                                removing = state.removingDeviceId == device.deviceId,
                                onRename = { actions.onRenameDevice(device) },
                                onRemove = { actions.onRemoveDevice(device) },
                                onCancelRemove = actions.onCancelDeviceRemoval,
                            )
                        }
                    }
                    state.devicesError?.let { SettingsNotice(it, positive = false) }
                    SettingsActionRow(
                        title = if (state.devicesLoading) "Refreshing…" else "Refresh the list",
                        description = "Check which TVs are signed in right now.",
                        badge = "REFRESH",
                        onClick = actions.onRefreshDevices,
                    )
                }
                SettingsPage.STORAGE -> SettingsGroup {
                    val cache = state.imageCacheSize
                    VersionRow(
                        "Stored artwork",
                        cache?.let { formatCacheSize(it.diskBytes) } ?: "Measuring…",
                    )
                    SettingDivider()
                    VersionRow(
                        "Artwork in memory",
                        cache?.let { formatCacheSize(it.memoryBytes) } ?: "Measuring…",
                    )
                    SettingDivider()
                    SettingsActionRow(
                        title = if (state.imageCacheClearing) "Clearing…" else "Clear stored artwork",
                        description = "Frees the space back up and fetches fresh artwork. " +
                            "Rows will take a moment longer to fill in the first time.",
                        badge = when {
                            state.imageCacheClearing -> "WORKING"
                            cache == null -> "CLEAR"
                            else -> formatCacheSize(cache.totalBytes).uppercase()
                        },
                        onClick = actions.onClearImageCache,
                    )
                    // Only the confirmation gets a tinted band. A standing explanation in
                    // the notice colour would be the loudest thing on a page whose whole
                    // job is to be quiet until somebody presses the one button on it.
                    if (state.imageCacheCleared && !state.imageCacheClearing) {
                        SettingsNotice("Stored artwork cleared.", positive = true)
                    }
                }
                SettingsPage.ABOUT -> SettingsGroup {
                    Row(
                        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
                            Text(
                                stringResource(R.string.app_name),
                                color = TextPrimary,
                                fontSize = 17.sp,
                                fontWeight = FontWeight.Bold,
                            )
                            Text(
                                "Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
                                color = TextSecondary,
                                fontSize = 13.sp,
                            )
                        }
                        Text(
                            "v${state.installedVersion}",
                            color = EmbyGreen,
                            fontSize = 12.sp,
                            fontWeight = FontWeight.Bold,
                        )
                    }
                    SettingDivider()
                    VersionRow(
                        label = "Memby gateway",
                        version = state.gatewayVersion.takeIf(String::isNotBlank) ?: "Not connected",
                    )
                }
            }
            if (state.selectedPage == SettingsPage.ABOUT) {
                VersionHistorySection(
                    releases = state.releaseHistory,
                    installedVersion = state.installedVersion,
                )
            }
            Spacer(Modifier.height(12.dp))
        }
    }
}

internal fun deviceDescription(device: GatewayDevice): String = buildList {
    if (device.current) add("This TV")
    device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") }
    device.lastSeenAt.takeIf(String::isNotBlank)?.let {
        add("Last active ${it.replace('T', ' ').substringBefore('.').removeSuffix("Z")}")
    }
}.joinToString("  •  ").ifBlank { "Signed in" }

@Composable
private fun DeviceManagementRow(
    device: GatewayDevice,
    pendingRemoval: Boolean,
    removing: Boolean,
    onRename: () -> Unit,
    onRemove: () -> Unit,
    onCancelRemove: () -> Unit,
) {
    Row(
        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                Text(
                    device.deviceName.ifBlank { "Memby TV" },
                    color = TextPrimary,
                    fontSize = 16.sp,
                    fontWeight = FontWeight.SemiBold,
                )
                if (device.current) {
                    Text(
                        "CURRENT TV",
                        color = EmbyGreen,
                        fontSize = 9.sp,
                        fontWeight = FontWeight.Bold,
                        letterSpacing = 0.6.sp,
                    )
                }
            }
            Text(deviceDescription(device), color = TextSecondary, fontSize = 12.sp, maxLines = 2)
        }
        if (pendingRemoval) {
            DeviceActionChip("Cancel", onCancelRemove)
            DeviceActionChip(if (removing) "Removing…" else "Confirm remove", onRemove, enabled = !removing)
        } else {
            DeviceActionChip("Rename", onRename)
            if (!device.current) DeviceActionChip("Remove", onRemove, destructive = true)
        }
    }
}

@Composable
private fun DeviceActionChip(
    label: String,
    onClick: () -> Unit,
    enabled: Boolean = true,
    destructive: Boolean = false,
) {
    var focused by remember { mutableStateOf(false) }
    val accent = if (destructive) Color(0xFFE86D68) else EmbyGreen
    Text(
        text = label,
        color = when {
            !enabled -> TextQuiet
            focused -> Canvas
            else -> accent
        },
        fontSize = 11.sp,
        fontWeight = FontWeight.Bold,
        modifier = Modifier
            .clip(RoundedCornerShape(8.dp))
            .background(if (focused) accent else accent.copy(alpha = if (enabled) 0.13f else 0.05f))
            .border(1.dp, if (focused) Color.White else accent.copy(alpha = 0.28f), RoundedCornerShape(8.dp))
            .onFocusChanged { focused = it.isFocused }
            .clickable(enabled = enabled, onClick = onClick)
            .padding(horizontal = 12.dp, vertical = 9.dp),
    )
}

@Composable
private fun DeviceRenameDialog(
    device: GatewayDevice,
    busy: Boolean,
    error: String?,
    onCancel: () -> Unit,
    onSave: (String) -> Unit,
) {
    var name by rememberSaveable(device.deviceId) { mutableStateOf(device.deviceName) }
    var fieldFocused by remember { mutableStateOf(false) }
    val fieldFocus = remember { FocusRequester() }
    val cancelFocus = remember { FocusRequester() }
    val saveFocus = remember { FocusRequester() }
    val valid = name.trim().isNotEmpty() && name.length <= 80
    BackHandler(enabled = !busy, onBack = onCancel)
    LaunchedEffect(device.deviceId) {
        delay(80L)
        runCatching { fieldFocus.requestFocus() }
    }
    Box(
        modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            modifier = Modifier
                .width(520.dp)
                .focusGroup()
                .clip(RoundedCornerShape(18.dp))
                .background(Panel)
                .border(1.dp, Hairline, RoundedCornerShape(18.dp))
                .padding(28.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Text("Rename device", color = TextPrimary, fontSize = 24.sp, fontWeight = FontWeight.Bold)
            Text("Choose a name that makes this TV easy to recognise.", color = TextSecondary, fontSize = 14.sp)
            BasicTextField(
                value = name,
                onValueChange = { if (it.length <= 80) name = it },
                singleLine = true,
                enabled = !busy,
                textStyle = TextStyle(color = TextPrimary, fontSize = 18.sp),
                cursorBrush = SolidColor(EmbyGreen),
                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
                keyboardActions = KeyboardActions(onDone = { if (valid && !busy) onSave(name) }),
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(fieldFocus)
                    .focusProperties { down = saveFocus }
                    .onFocusChanged { fieldFocused = it.isFocused }
                    .clip(RoundedCornerShape(10.dp))
                    .background(ControlIdle)
                    .border(
                        if (fieldFocused) 2.dp else 1.dp,
                        if (fieldFocused) EmbyGreen else Hairline,
                        RoundedCornerShape(10.dp),
                    )
                    .padding(horizontal = 16.dp, vertical = 14.dp),
            )
            error?.let { Text(it, color = Color(0xFFFF9B98), fontSize = 13.sp) }
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
            ) {
                Button(
                    onClick = onCancel,
                    enabled = !busy,
                    modifier = Modifier.focusRequester(cancelFocus).focusProperties { right = saveFocus },
                ) { Text("Cancel") }
                Button(
                    onClick = { onSave(name) },
                    enabled = valid && !busy,
                    modifier = Modifier.focusRequester(saveFocus).focusProperties { left = cancelFocus },
                ) { Text(if (busy) "Saving…" else "Save") }
            }
        }
    }
}

/**
 * How long a rail item must hold focus before its page is actually drawn.
 *
 * Focus moving down the rail is what selects a page, which is the right gesture and the
 * expensive one: walking from Appearance to About passes four pages, and each of them was
 * composed in full — Devices going as far as asking the gateway who is signed in — under a
 * thumb that had already moved on. On a weak set that is what made the rail feel as though
 * it had stuck. The highlight still follows the remote with no delay, because that is the
 * part a viewer is watching; only the pane waits to see where they stopped. Short enough
 * that a deliberate press never feels held up, the same trade as the launcher's own
 * FOCUS_METADATA_DEBOUNCE_MS.
 */
private const val SETTINGS_PAGE_SETTLE_MS = 130L

@Composable
private fun SettingsSecondaryRail(
    selected: SettingsPage,
    onSelected: (SettingsPage) -> Unit,
    onClose: () -> Unit,
    firstFocusRequester: FocusRequester?,
    navigationFocusRequester: FocusRequester?,
    contentFocusRequester: FocusRequester,
    compact: Boolean,
    modifier: Modifier = Modifier,
) {
    val pages = SettingsPage.entries.filter { it.showInRail }
    val selectedRailPage = selected
    val scope = rememberCoroutineScope()
    // Which item the remote is on, which is deliberately not the same thing as which page
    // is drawn. Everything the rail paints reads from this, so nothing about the highlight
    // waits on the settle above.
    var focusedPage by remember { mutableStateOf(selected) }
    LaunchedEffect(focusedPage, selected) {
        if (focusedPage == selected) return@LaunchedEffect
        delay(SETTINGS_PAGE_SETTLE_MS)
        onSelected(focusedPage)
    }
    // The home screen remains composed behind this panel. Relying on spatial focus
    // search therefore lets a covered media card beat the next rail item when their
    // bounds happen to be closer (notably below Playback on a 540p viewport). Give
    // every vertical move an explicit destination so focus cannot leak through the
    // settings surface and activate home content.
    val railFocusRequesters = remember(firstFocusRequester) {
        buildList {
            add(FocusRequester()) // Back
            pages.forEach { page ->
                add(
                    if (page == selectedRailPage && firstFocusRequester != null) {
                        firstFocusRequester
                    } else {
                        FocusRequester()
                    },
                )
            }
        }
    }
    Column(
        modifier = modifier
            .background(Canvas)
            // A single hairline down the right edge instead of a box around the rail. The
            // rail is not a card; it is the left edge of the screen.
            .drawBehind {
                drawRect(
                    color = RailEdge,
                    topLeft = Offset(size.width - 1f, 0f),
                    size = Size(1f, size.height),
                )
            }
            .verticalScroll(rememberScrollState())
            .padding(
                horizontal = if (compact) 14.dp else 18.dp,
                vertical = if (compact) 28.dp else 38.dp,
            ),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Text(
            "SETTINGS",
            color = TextQuiet,
            fontSize = 10.sp,
            fontWeight = FontWeight.Bold,
            letterSpacing = 1.4.sp,
            modifier = Modifier.padding(start = 12.dp, bottom = 8.dp),
        )
        SettingsBackRailItem(
            compact = compact,
            onClick = onClose,
            focusRequester = railFocusRequesters[0],
            downFocusRequester = railFocusRequesters[1],
            leftFocusRequester = navigationFocusRequester,
            rightFocusRequester = contentFocusRequester,
        )
        pages.forEachIndexed { index, page ->
            var focused by remember { mutableStateOf(false) }
            val active = page == focusedPage
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .clip(RoundedCornerShape(10.dp))
                    .background(
                        when {
                            focused -> Color.White
                            active -> EmbyGreen.copy(alpha = 0.16f)
                            else -> Color.Transparent
                        },
                    )
                    .border(
                        width = 1.dp,
                        color = when {
                            focused -> Color.White
                            active -> EmbyGreen.copy(alpha = 0.40f)
                            else -> Color.Transparent
                        },
                        shape = RoundedCornerShape(10.dp),
                    )
                    .focusRequester(railFocusRequesters[index + 1])
                    .focusProperties {
                        up = railFocusRequesters[index]
                        down = if (index == pages.lastIndex) {
                            FocusRequester.Cancel
                        } else {
                            railFocusRequesters[index + 2]
                        }
                        // Left leaves for the app's main rail when it is beside us, and is
                        // cancelled rather than left to spatial search otherwise: the home
                        // screen stays composed behind this panel and a covered card can
                        // otherwise win the focus search.
                        left = navigationFocusRequester ?: FocusRequester.Cancel
                        right = contentFocusRequester
                    }
                    .onPreviewKeyEvent { event ->
                        if (
                            event.type != KeyEventType.KeyDown ||
                            event.key != Key.DirectionRight ||
                            page == selected
                        ) {
                            return@onPreviewKeyEvent false
                        }
                        // Right while a settle is still pending. Letting the focus property
                        // run would enter the page the viewer has already walked past, and
                        // lose focus again the moment it is replaced — the same dead end the
                        // requester exists to close. Draw the page they are actually on
                        // first, then hand focus to it once it has composed.
                        onSelected(page)
                        scope.launch {
                            delay(32L)
                            runCatching { contentFocusRequester.requestFocus() }
                        }
                        true
                    }
                    .onFocusChanged {
                        focused = it.isFocused
                        if (it.isFocused) focusedPage = page
                    }
                    .testTag("settings-rail-${page.name.lowercase()}")
                    .clickable {
                        focusedPage = page
                        onSelected(page)
                    }
                    .padding(horizontal = 12.dp, vertical = 12.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(10.dp),
            ) {
                Icon(
                    page.icon,
                    contentDescription = null,
                    tint = when {
                        focused -> Canvas
                        active -> EmbyGreen
                        else -> TextSecondary
                    },
                    modifier = Modifier.size(19.dp),
                )
                Text(
                    page.label,
                    color = if (focused) Canvas else TextPrimary,
                    fontSize = if (compact) 13.sp else 14.sp,
                    fontWeight = if (active || focused) FontWeight.Bold else FontWeight.Medium,
                    maxLines = 1,
                )
            }
        }
        Spacer(Modifier.height(16.dp))
        Text(
            "↑ ↓ moves between pages",
            color = TextQuiet,
            fontSize = 11.sp,
            lineHeight = 16.sp,
            modifier = Modifier.padding(horizontal = 12.dp),
        )
    }
}

@Composable
private fun SettingsBackRailItem(
    compact: Boolean,
    onClick: () -> Unit,
    focusRequester: FocusRequester,
    downFocusRequester: FocusRequester,
    leftFocusRequester: FocusRequester?,
    rightFocusRequester: FocusRequester,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(10.dp))
            .background(if (focused) Color.White else Color.Transparent)
            .border(
                width = 1.dp,
                color = if (focused) Color.White else Color.Transparent,
                shape = RoundedCornerShape(10.dp),
            )
            .focusRequester(focusRequester)
            .focusProperties {
                up = FocusRequester.Cancel
                down = downFocusRequester
                left = leftFocusRequester ?: FocusRequester.Cancel
                right = rightFocusRequester
            }
            .onFocusChanged { focused = it.isFocused }
            .clickable(onClick = onClick)
            .padding(horizontal = 12.dp, vertical = 12.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Icon(
            Icons.AutoMirrored.Filled.ArrowBack,
            contentDescription = null,
            tint = if (focused) Canvas else TextSecondary,
            modifier = Modifier.size(19.dp),
        )
        Text(
            "Back",
            color = if (focused) Canvas else TextPrimary,
            fontSize = if (compact) 13.sp else 14.sp,
            fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
            maxLines = 1,
        )
    }
}

@Composable
private fun SettingsHeader(page: SettingsPage) {
    Row(
        verticalAlignment = Alignment.CenterVertically,
        // Indented to the rows' own text rather than to the panel edge. With the section
        // card gone, a row's padding is what insets its focus highlight from its label, and
        // a header sitting 16dp left of every setting under it reads as a second column.
        modifier = Modifier
            .padding(horizontal = 16.dp)
            .testTag("settings-page-${page.name.lowercase()}"),
    ) {
        // No eyebrow above the title. It said "MEMBY · TV" on every page of a sheet that is
        // already inside Memby on a television, so it named the one thing nobody could be in
        // doubt about while taking the vertical space the settings themselves want.
        //
        // No version in the corner either, for the same reason it lost the eyebrow: it was
        // on all seven pages to answer a question asked on two of them, where Updates prints
        // it as "On this TV" and About prints it beside the app's own name.
        Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
            Text(
                page.description,
                color = TextSecondary,
                fontSize = 13.sp,
            )
        }
    }
}

@Composable
private fun VersionRow(label: String, version: String) {
    Row(
        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(label, color = TextSecondary, fontSize = 14.sp, modifier = Modifier.weight(1f))
        Text(
            version,
            color = TextPrimary,
            fontSize = 15.sp,
            fontWeight = FontWeight.Bold,
        )
    }
}

/**
 * A run of settings on the black canvas.
 *
 * There is no card and no icon chip. Every page but About holds exactly one of these, and
 * the header above it already names the page — a titled box inside a titled page said the
 * same word twice and cost two nested surfaces to do it. [label] exists only for the pages
 * that genuinely have two groups, and it is a quiet caption rather than a second heading.
 */
@Composable
private fun SettingsGroup(
    label: String? = null,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
        label?.let {
            Text(
                it.uppercase(),
                color = TextQuiet,
                fontSize = 10.sp,
                fontWeight = FontWeight.Bold,
                letterSpacing = 1.2.sp,
                modifier = Modifier.padding(start = 16.dp, top = 10.dp),
            )
        }
        Column(modifier = Modifier.fillMaxWidth(), content = content)
    }
}

@Composable
private fun SettingsToggleRow(
    title: String,
    description: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(10.dp))
            .background(if (focused) RowFocused else Color.Transparent)
            .border(
                width = if (focused) 2.dp else 1.dp,
                color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
                shape = RoundedCornerShape(10.dp),
            )
            .onFocusChanged { focused = it.isFocused }
            .clickable { onCheckedChange(!checked) }
            .padding(horizontal = 16.dp, vertical = 14.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(18.dp),
    ) {
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
            Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
            Text(description, color = TextSecondary, fontSize = 13.sp, maxLines = 2)
        }
        StatusToggle(checked = checked)
    }
}

@Composable
private fun StatusToggle(checked: Boolean) {
    Row(
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(9.dp),
    ) {
        Text(
            if (checked) "ON" else "OFF",
            color = if (checked) EmbyGreen else TextQuiet,
            fontSize = 11.sp,
            fontWeight = FontWeight.Bold,
            letterSpacing = 0.8.sp,
        )
        Box(
            modifier = Modifier
                .width(46.dp)
                .height(25.dp)
                .clip(CircleShape)
                .background(if (checked) EmbyGreen else Color(0xFF394149))
                .padding(3.dp),
        ) {
            Box(
                modifier = Modifier
                    .align(if (checked) Alignment.CenterEnd else Alignment.CenterStart)
                    .size(19.dp)
                    .clip(CircleShape)
                    .background(if (checked) Color(0xFF072108) else Color(0xFFCBD2D7)),
            )
        }
    }
}

/**
 * The colour scheme picker.
 *
 * It is its own row rather than a [SettingsChoiceRow] for one reason: this is the only
 * setting on the television that the viewer can be *temporarily overruled* on. While a
 * season is in force the chips still show what they chose — it is still theirs, and it comes
 * back — but they do nothing, and the line underneath says so in the server's words rather
 * than in wording this build guessed at. A greyed row with no explanation would read as a
 * broken setting, which is exactly what somebody would report in December.
 */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun SettingsColourSchemeRow(
    options: List<ChoiceOption>,
    selected: String,
    locked: Boolean,
    notice: String,
    onSelected: (String) -> Unit,
) {
    Column(
        modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
        verticalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
            Text(
                "Colour scheme",
                color = TextPrimary,
                fontSize = 16.sp,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                "The colours Memby uses, on every TV you sign in to.",
                color = TextSecondary,
                fontSize = 13.sp,
            )
        }
        // Wraps, unlike every other choice row on this page. Those offer three fixed
        // options and will never offer four; this one is a server catalogue that grows
        // without an app release, and six chips already reach the right edge of a 960dp
        // set — the seventh would be off the screen with nothing to say it was there.
        FlowRow(
            horizontalArrangement = Arrangement.spacedBy(9.dp),
            verticalArrangement = Arrangement.spacedBy(9.dp),
        ) {
            options.forEach { option ->
                SettingsChoiceChip(
                    option = option,
                    selected = selected.equals(option.value, ignoreCase = true),
                    // Still focusable while locked, so a viewer can read across the row and
                    // see what is theirs; the press simply does nothing.
                    onClick = { if (!locked) onSelected(option.value) },
                )
            }
        }
        if (locked) {
            Text(
                notice.ifBlank { "A seasonal scheme is on for everyone at the moment." },
                color = TextQuiet,
                fontSize = 13.sp,
            )
        }
    }
}

@Composable
private fun SettingsChoiceRow(
    title: String,
    description: String,
    options: List<ChoiceOption>,
    selected: String,
    onSelected: (String) -> Unit,
) {
    Column(
        modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
        verticalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
            Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
            Text(description, color = TextSecondary, fontSize = 13.sp)
        }
        Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
            options.forEach { option ->
                SettingsChoiceChip(
                    option = option,
                    selected = selected.equals(option.value, ignoreCase = true),
                    onClick = { onSelected(option.value) },
                )
            }
        }
    }
}

@Composable
private fun SettingsChoiceChip(
    option: ChoiceOption,
    selected: Boolean,
    onClick: () -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = Modifier
            .clip(RoundedCornerShape(999.dp))
            .background(
                when {
                    selected -> EmbyGreen
                    focused -> RowFocused
                    else -> ControlIdle
                },
            )
            .border(
                width = if (focused) 2.dp else 1.dp,
                color = when {
                    focused -> Color.White
                    selected -> EmbyGreen
                    else -> Color.White.copy(alpha = 0.12f)
                },
                shape = RoundedCornerShape(999.dp),
            )
            .onFocusChanged { focused = it.isFocused }
            .clickable(onClick = onClick)
            .padding(horizontal = 15.dp, vertical = 9.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(7.dp),
    ) {
        option.color?.let {
            Box(
                Modifier
                    .size(10.dp)
                    .clip(CircleShape)
                    .background(it)
                    .border(1.dp, Color.Black.copy(alpha = 0.25f), CircleShape),
            )
        }
        Text(
            option.label,
            color = if (selected) Color(0xFF062307) else TextPrimary,
            fontSize = 12.sp,
            fontWeight = FontWeight.Bold,
        )
    }
}

@Composable
private fun SettingsActionRow(
    title: String,
    description: String,
    badge: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(10.dp))
            .background(if (focused) RowFocused else Color.Transparent)
            .border(
                width = if (focused) 2.dp else 1.dp,
                color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
                shape = RoundedCornerShape(10.dp),
            )
            .onFocusChanged { focused = it.isFocused }
            .clickable(onClick = onClick)
            .padding(horizontal = 16.dp, vertical = 14.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
            Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
            Text(description, color = TextSecondary, fontSize = 13.sp)
        }
        Text(
            badge,
            color = if (focused) Color(0xFF062307) else EmbyGreen,
            fontSize = 10.sp,
            fontWeight = FontWeight.Bold,
            letterSpacing = 0.7.sp,
            modifier = Modifier
                .clip(RoundedCornerShape(999.dp))
                .background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f))
                .padding(horizontal = 11.dp, vertical = 7.dp),
        )
    }
}

@Composable
private fun SettingsNotice(text: String, positive: Boolean) {
    Text(
        text,
        color = if (positive) Color(0xFF9DE29A) else Color(0xFFFF9B98),
        fontSize = 13.sp,
        fontWeight = FontWeight.SemiBold,
        modifier = Modifier
            .fillMaxWidth()
            // Inset to the same 16dp as the rows and the dividers, so the one tinted band on
            // a black page lines up with everything above it instead of hanging off both edges.
            .padding(horizontal = 16.dp, vertical = 4.dp)
            .clip(RoundedCornerShape(8.dp))
            .background(
                if (positive) EmbyGreen.copy(alpha = 0.10f) else Color(0xFFE75852).copy(alpha = 0.12f),
            )
            .padding(horizontal = 14.dp, vertical = 10.dp),
    )
}

/**
 * The release history, as a list of collapsed releases that open in place. A remote has
 * no scrollbar to aim at, so the whole changelog rendered flat would be a very long blind
 * scroll; one focusable row per release makes Down mean "next release" until the viewer
 * asks for a particular one. The newest is open on arrival because that is the one being
 * looked for.
 */
@Composable
private fun VersionHistorySection(
    releases: List<ReleaseNote>,
    installedVersion: String,
) {
    var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
        mutableStateOf(releases.firstOrNull()?.version)
    }
    SettingsGroup(label = "Changelog") {
        if (releases.isEmpty()) {
            SettingsNotice("No release history shipped with this build.", positive = false)
            return@SettingsGroup
        }
        releases.forEachIndexed { index, release ->
            if (index > 0) SettingDivider()
            ReleaseHistoryRow(
                release = release,
                installed = release.version == installedVersion,
                expanded = release.version == expandedVersion,
                onToggle = {
                    expandedVersion = if (expandedVersion == release.version) null else release.version
                },
            )
        }
    }
}

@Composable
private fun ReleaseHistoryRow(
    release: ReleaseNote,
    installed: Boolean,
    expanded: Boolean,
    onToggle: () -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(10.dp))
            .background(if (focused) RowFocused else Color.Transparent)
            .border(
                width = if (focused) 2.dp else 1.dp,
                color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
                shape = RoundedCornerShape(10.dp),
            )
            .onFocusChanged { focused = it.isFocused }
            .clickable(onClick = onToggle)
            .testTag("settings-release-${release.version}")
            .padding(horizontal = 16.dp, vertical = 13.dp),
        verticalArrangement = Arrangement.spacedBy(9.dp),
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(10.dp),
        ) {
            Text(
                "v${release.version}",
                color = if (installed) EmbyGreen else TextPrimary,
                fontSize = 16.sp,
                fontWeight = FontWeight.Bold,
            )
            if (installed) {
                Text(
                    "INSTALLED",
                    color = EmbyGreen,
                    fontSize = 9.sp,
                    fontWeight = FontWeight.Bold,
                    letterSpacing = 0.6.sp,
                )
            }
            Spacer(Modifier.weight(1f))
            release.date.takeIf(String::isNotBlank)?.let {
                Text(it, color = TextQuiet, fontSize = 12.sp)
            }
            Text(
                if (expanded) "HIDE" else "${release.changes.size} CHANGES",
                color = if (focused) Color(0xFF062307) else EmbyGreen,
                fontSize = 10.sp,
                fontWeight = FontWeight.Bold,
                letterSpacing = 0.7.sp,
                modifier = Modifier
                    .clip(RoundedCornerShape(999.dp))
                    .background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f))
                    .padding(horizontal = 11.dp, vertical = 7.dp),
            )
        }
        AnimatedVisibility(visible = expanded) {
            Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
                release.changes.forEach { change ->
                    Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
                        Text("•", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold)
                        Text(change, color = TextSecondary, fontSize = 13.sp, lineHeight = 19.sp)
                    }
                }
            }
        }
    }
}

@Composable
private fun SettingDivider() {
    Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(1.dp).background(Hairline))
}

@TvPreview
@Composable
private fun SettingsPanelPreview() {
    PreviewSurface {
        SettingsPanelContent(
            state = SettingsPanelState(
                showLogo = true,
                autoPlayNext = true,
                ringColor = "52B54B",
                homeSections = setOf("continue", "favorites"),
                cardDensity = "standard",
                showCardMetadata = false,
                welcomeQuoteStyle = "positive",
                installedVersion = "0.1.60",
            ),
            actions = SettingsPanelActions(),
            overlay = false,
        )
    }
}
