package com.ponzischeme89.memby.ui

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.input.key.onPreviewKeyEvent
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.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlaylistRemove
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.channelLabel
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

// The launcher's names for the shared tokens. Both surfaces read from one palette now
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
// so a green or a grey that differs by a shade differs in front of the viewer.
// Getters rather than values: the tokens are snapshot state now that the palette is the
// server's answer, and capturing one here would pin the launcher to the theme that happened
// to be loaded when this class initialised.
private val EmbyGreen: Color get() = MembyAccent

// The rail sits over the launcher rather than beside it, so it is the surface at the
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
// the reasons a theme change left most of the screen looking untouched.
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
private val MutedText: Color get() = MembyMutedText
private val QuietText: Color get() = MembyQuietText
internal val TvRailCollapsedWidth = 54.dp
internal val TvRailExpandedWidth = 184.dp
internal val TvRailContentShift = 112.dp

enum class BrowseDestination(val label: String, val icon: ImageVector) {
    HOME("Home", Icons.Default.Home),
    FOR_YOU("For You", Icons.Default.AutoAwesome),
    SEARCH("Search", Icons.Default.Search),
    MOVIES("Movies", Icons.Default.Movie),
    SHOWS("TV Shows", Icons.Default.Tv),
    // Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
    // has one — see [TvNavigationRail]'s calendarEnabled.
    CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
    FAVORITES("Favourites", Icons.Default.Favorite),
    PROFILES("Switch user", Icons.Default.Person),
    SETTINGS("Settings", Icons.Default.Settings),
}

// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }

data class HomeBrowseRow(
    val id: String,
    val title: String,
    val items: List<BaseItem>,
    val kind: MediaRowKind,
    val loading: Boolean = false,
    val emptyMessage: String,
    val showSecondaryMetadata: Boolean = true,
    val showWatchedEpisodeCount: Boolean = false,
)

private data class HomeRowVisual(
    val icon: ImageVector,
)

private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
    row.id == "continue" -> HomeRowVisual(
        Icons.Default.PlayCircleFilled,
    )
    row.id == "continue-shows" -> HomeRowVisual(
        Icons.Default.PlayCircleFilled,
    )
    row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
        Icons.Default.Favorite,
    )
    row.id == "latest-movies" -> HomeRowVisual(
        Icons.Default.Movie,
    )
    row.id == "sonarr-airing-today" -> HomeRowVisual(
        Icons.Default.CalendarMonth,
    )
    row.id == "curated:apple-tv" -> HomeRowVisual(
        Icons.Default.LiveTv,
    )
    row.id == "curated:drama-shows" -> HomeRowVisual(
        Icons.Default.TheaterComedy,
    )
    row.id == "curated:comedy-shows" -> HomeRowVisual(
        Icons.Default.SentimentVerySatisfied,
    )
    row.id.startsWith("similar:") -> HomeRowVisual(
        Icons.Default.AutoAwesome,
    )
    row.id == "recommended" -> HomeRowVisual(
        Icons.Default.Recommend,
    )
    row.id.startsWith("for-you:") -> HomeRowVisual(
        Icons.Default.AutoAwesome,
    )
    else -> HomeRowVisual(
        Icons.Default.VideoLibrary,
    )
}

@Composable
fun TvNavigationRail(
    selected: BrowseDestination,
    focusDestination: BrowseDestination = selected,
    expanded: Boolean,
    navigationFocusRequester: FocusRequester,
    onRailFocusChanged: (Boolean) -> Unit,
    onDestinationSelected: (BrowseDestination) -> Unit,
    modifier: Modifier = Modifier,
    alertCount: Int = 0,
    activeUsername: String = "",
    calendarEnabled: Boolean = false,
) {
    var railHasFocus by remember { mutableStateOf(false) }
    val logoScale = remember { Animatable(0.72f) }
    val logoAlpha = remember { Animatable(0f) }
    val logoRotation = remember { Animatable(0f) }
    LaunchedEffect(Unit) {
        kotlinx.coroutines.coroutineScope {
            launch { logoScale.animateTo(1f, tween(620)) }
            launch { logoAlpha.animateTo(1f, tween(420)) }
        }
    }
    LaunchedEffect(selected) {
        logoRotation.snapTo(0f)
        logoRotation.animateTo(360f, tween(850))
    }
    LaunchedEffect(railHasFocus) {
        if (railHasFocus) {
            // Spatial focus may initially choose the first rail item (Home). Correct that
            // only as focus enters the rail; subsequent up/down movement must remain free.
            runCatching { navigationFocusRequester.requestFocus() }
        }
    }
    val railWidth = animateDpAsState(
        targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
        animationSpec = tween(160),
        label = "navigation-rail-width",
    )
    Box(
        modifier = modifier
            .width(TvRailCollapsedWidth)
            .fillMaxHeight()
            .zIndex(8f),
    ) {
        Column(
            modifier = Modifier
                // The parent deliberately reports only the collapsed footprint to the
                // home Row. requiredWidth lets the focused surface draw outward without
                // remeasuring gallery cards or clipping their labels to 54dp.
                // Only width may escape the collapsed 54dp footprint. Unbounding height
                // here would make fillMaxHeight lose the screen constraint and stop the
                // rail surface at its final child (the version label).
                .wrapContentWidth(Alignment.Start, unbounded = true)
                // Read in the measure lambda, not the modifier chain: expanding the rail
                // is the most frequent animation in the app, and reading the animated Dp
                // during composition recomposes the rail and every item in it per frame.
                .layout { measurable, constraints ->
                    val width = railWidth.value.roundToPx()
                    val placeable = measurable.measure(
                        constraints.copy(minWidth = width, maxWidth = width),
                    )
                    layout(placeable.width, placeable.height) { placeable.place(0, 0) }
                }
                .fillMaxHeight()
                .background(RailSurface)
                .onFocusChanged { focusState ->
                    val hasFocus = focusState.hasFocus
                    if (railHasFocus != hasFocus) {
                        railHasFocus = hasFocus
                        onRailFocusChanged(hasFocus)
                    }
                }
                .focusGroup()
                .padding(horizontal = 5.dp, vertical = 15.dp),
            horizontalAlignment = Alignment.Start,
        ) {
            Row(
                modifier = Modifier.height(42.dp).padding(horizontal = 6.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(12.dp),
            ) {
                Image(
                    painter = painterResource(R.drawable.emby_logo),
                    contentDescription = "Memby",
                    modifier = Modifier
                        .width(32.dp)
                        .height(27.dp)
                        .graphicsLayer {
                            scaleX = logoScale.value
                            scaleY = logoScale.value
                            alpha = logoAlpha.value
                            rotationZ = logoRotation.value
                        },
                )
                if (expanded) {
                    Column(verticalArrangement = Arrangement.Center) {
                        Text(
                            "Memby",
                            color = Color.White,
                            fontSize = 18.sp,
                            fontWeight = FontWeight.Bold,
                            maxLines = 1,
                        )
                        Text(
                            "Matt’s Android TV client",
                            color = QuietText,
                            fontSize = 9.sp,
                            fontWeight = FontWeight.Medium,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                    }
                }
            }
            Spacer(Modifier.height(16.dp))
            // A destination with nothing behind it is worse than one fewer: the calendar
            // needs the gateway and a Sonarr, and a household with neither would otherwise
            // carry a rail item that only ever opens an apology.
            val destinations = remember(calendarEnabled) {
                BrowseDestination.entries.filter {
                    it != BrowseDestination.CALENDAR || calendarEnabled
                }
            }
            destinations.forEach { destination ->
                ExpandableNavigationItem(
                    destination = destination,
                    selected = destination == selected,
                    expanded = expanded,
                    modifier = if (destination == focusDestination) {
                        Modifier.focusRequester(navigationFocusRequester)
                    } else {
                        Modifier
                    },
                    onFocused = {},
                    onClick = { onDestinationSelected(destination) },
                    // My Alerts is one level in, behind the user picker. Without a mark out
                    // here nothing on the launcher would ever say there was news waiting.
                    badge = if (destination == BrowseDestination.PROFILES) {
                        alertBadgeLabel(alertCount)
                    } else {
                        null
                    },
                    avatarInitials = activeUsername.takeIf {
                        destination == BrowseDestination.PROFILES
                    }?.let(::profileInitials),
                )
                Spacer(Modifier.height(4.dp))
            }
            Spacer(Modifier.weight(1f))
            Text(
                if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
                color = QuietText,
                fontSize = if (expanded) 10.sp else 8.sp,
                fontWeight = FontWeight.Medium,
                maxLines = 1,
                modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
            )
        }
    }
}

@Composable
fun UserSwitcherOverlay(
    profiles: List<EmbyProfile>,
    activeProfileId: String?,
    onProfileSelected: (EmbyProfile) -> Unit,
    onManageProfiles: () -> Unit,
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier,
    alertCount: Int = 0,
    onOpenAlerts: () -> Unit = {},
) {
    val profileIds = profiles.map(EmbyProfile::id)
    val focusRequesters = remember(profileIds) {
        List(profiles.size + UserSwitcherActionCount) { FocusRequester() }
    }
    val profileListState = remember(profileIds) { LazyListState() }
    val focusScope = rememberCoroutineScope()
    var profileFocusJob by remember(profileIds) { mutableStateOf<kotlinx.coroutines.Job?>(null) }
    var focusedIndex by remember(profileIds) {
        mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId))
    }
    LaunchedEffect(profileIds, activeProfileId) {
        profileFocusJob?.cancel()
        focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId)
        if (profiles.isNotEmpty()) {
            profileListState.scrollToItem(focusedIndex)
        }
        // Let a lazily composed off-screen profile attach its FocusRequester first.
        delay(16)
        runCatching { focusRequesters[focusedIndex].requestFocus() }
    }

    Box(
        modifier
            .fillMaxSize()
            .zIndex(20f)
            .background(Color.Black.copy(alpha = 0.56f)),
    ) {
        Column(
            modifier = Modifier
                .align(Alignment.CenterStart)
                .padding(start = 52.dp)
                .width(292.dp)
                .heightIn(max = 400.dp)
                .shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
                .clip(RoundedCornerShape(MembyPanelCorner))
                .background(MembySurface)
                .border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
                .onPreviewKeyEvent { event ->
                    if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
                    when (event.key) {
                        Key.DirectionUp, Key.DirectionDown -> {
                            val direction = if (event.key == Key.DirectionUp) {
                                UserSwitcherDirection.UP
                            } else {
                                UserSwitcherDirection.DOWN
                            }
                            val next = userSwitcherNextIndex(
                                currentIndex = focusedIndex,
                                profileCount = profiles.size,
                                direction = direction,
                                actionCount = UserSwitcherActionCount,
                            )
                            focusedIndex = next
                            profileFocusJob?.cancel()
                            if (next < profiles.size) {
                                profileFocusJob = focusScope.launch {
                                    profileListState.scrollToItem(next)
                                    delay(16)
                                    runCatching { focusRequesters[next].requestFocus() }
                                }
                            } else {
                                runCatching { focusRequesters[next].requestFocus() }
                            }
                            true
                        }
                        Key.DirectionLeft, Key.Back -> {
                            onDismiss()
                            true
                        }
                        // Keep focus inside this modal instead of allowing spatial focus
                        // search to land on a dimmed card behind it.
                        Key.DirectionRight -> true
                        else -> false
                    }
                }
                .padding(10.dp),
        ) {
            Text(
                "Switch user",
                color = Color.White,
                fontSize = 18.sp,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
            )
            Text(
                "Choose who’s watching",
                color = QuietText,
                fontSize = 12.sp,
                modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
            )
            LazyColumn(
                state = profileListState,
                modifier = Modifier
                    .fillMaxWidth()
                    .heightIn(max = 248.dp),
                verticalArrangement = Arrangement.spacedBy(2.dp),
            ) {
                itemsIndexed(
                    items = profiles,
                    key = { _, profile -> profile.id },
                ) { index, profile ->
                    UserSwitcherProfileItem(
                        profile = profile,
                        current = profile.id == activeProfileId,
                        modifier = Modifier
                            .focusRequester(focusRequesters[index])
                            .onFocusChanged { if (it.isFocused) focusedIndex = index },
                        onClick = { onProfileSelected(profile) },
                    )
                }
            }
            Spacer(Modifier.height(6.dp))
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(1.dp)
                    .background(Color.White.copy(alpha = 0.07f)),
            )
            Spacer(Modifier.height(6.dp))
            // My Alerts lives here rather than on the launcher: these alerts belong to a
            // person and follow them between televisions, so the menu that already answers
            // "who is watching" is where somebody looks for their own news. The badge is
            // what replaces the bell that used to sit in the corner of Home.
            UserSwitcherAction(
                label = "My Alerts",
                icon = Icons.Default.Notifications,
                badge = alertBadgeLabel(alertCount),
                modifier = Modifier
                    .focusRequester(focusRequesters[profiles.size])
                    .onFocusChanged {
                        if (it.isFocused) focusedIndex = profiles.size
                    },
                onClick = onOpenAlerts,
            )
            UserSwitcherAction(
                label = "Manage users",
                icon = Icons.Default.Settings,
                modifier = Modifier
                    .focusRequester(focusRequesters[profiles.size + 1])
                    .onFocusChanged {
                        if (it.isFocused) focusedIndex = profiles.size + 1
                    },
                onClick = onManageProfiles,
            )
        }
    }
}

/** My Alerts, then Manage users. See [userSwitcherNextIndex]. */
private const val UserSwitcherActionCount = 2

@Composable
private fun UserSwitcherProfileItem(
    profile: EmbyProfile,
    current: Boolean,
    modifier: Modifier = Modifier,
    onClick: () -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(44.dp)
            .onFocusChanged { focused = it.isFocused }
            .clip(RoundedCornerShape(MembyChipCorner))
            .background(
                when {
                    focused -> Color.White.copy(alpha = 0.11f)
                    current -> EmbyGreen.copy(alpha = 0.07f)
                    else -> Color.Transparent
                },
            )
            .clickable(onClick = onClick)
            .semantics {
                contentDescription =
                    if (current) "${profile.username}, current user" else profile.username
            }
            .padding(horizontal = 10.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Box(
            Modifier
                .size(26.dp)
                .background(if (current) EmbyGreen else MembyControlSurfaceRaised, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                profileInitials(profile.username),
                color = Color.White,
                fontSize = 11.sp,
                fontWeight = FontWeight.Bold,
            )
        }
        Text(
            profile.username,
            color = if (focused || current) Color.White else MutedText,
            fontSize = 14.sp,
            fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.weight(1f),
        )
        if (current) {
            Icon(
                Icons.Default.CheckCircle,
                contentDescription = null,
                tint = EmbyGreen,
                modifier = Modifier.size(14.dp),
            )
        }
    }
}

@Composable
private fun UserSwitcherAction(
    label: String,
    icon: ImageVector,
    modifier: Modifier = Modifier,
    badge: String? = null,
    onClick: () -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(40.dp)
            .onFocusChanged { focused = it.isFocused }
            .clip(RoundedCornerShape(MembyChipCorner))
            .background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
            .clickable(onClick = onClick)
            .semantics {
                contentDescription = badge?.let { "$label, $it waiting" } ?: label
            }
            .padding(horizontal = 12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(
            icon,
            contentDescription = null,
            tint = if (focused) Color.White else QuietText,
            modifier = Modifier.size(17.dp),
        )
        Spacer(Modifier.width(12.dp))
        Text(
            label,
            color = if (focused) Color.White else QuietText,
            fontSize = 13.sp,
            fontWeight = FontWeight.SemiBold,
        )
        if (badge != null) {
            Spacer(Modifier.weight(1f))
            Text(
                badge,
                color = Color.White,
                fontSize = 10.sp,
                fontWeight = FontWeight.Bold,
                modifier = Modifier
                    .background(AlertBadgeRed, CircleShape)
                    .padding(horizontal = 6.dp, vertical = 2.dp),
            )
        }
    }
}

/** The one red a waiting-alert badge is drawn in, on the rail and in the user menu alike. */
private val AlertBadgeRed = Color(0xFFE04747)

@Composable
fun ExpandableNavigationItem(
    destination: BrowseDestination,
    selected: Boolean,
    expanded: Boolean,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    badge: String? = null,
    avatarInitials: String? = null,
) {
    var focused by remember { mutableStateOf(false) }
    // Not `by`: both colours are read in the draw phase / at the point of use, so the
    // 85ms focus transition repaints this row rather than recomposing it.
    val background = animateColorAsState(
        targetValue = when {
            focused -> Color.White.copy(alpha = 0.13f)
            selected -> EmbyGreen.copy(alpha = 0.10f)
            else -> Color.Transparent
        },
        animationSpec = tween(85),
        label = "navigation-item-background",
    )
    val foreground = animateColorAsState(
        targetValue = when {
            focused -> Color.White
            selected -> EmbyGreen
            else -> MutedText
        },
        animationSpec = tween(85),
        label = "navigation-item-foreground",
    )
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(44.dp)
            .onFocusChanged {
                focused = it.isFocused
                if (it.isFocused) onFocused()
            }
            .clip(RoundedCornerShape(8.dp))
            .drawBehind { drawRect(background.value) }
            .clickable(onClick = onClick)
            .semantics {
                contentDescription = badge?.let { "${destination.label}, $it waiting" }
                    ?: destination.label
            }
            .padding(horizontal = 8.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
            if (avatarInitials != null) {
                Box(
                    Modifier
                        .size(25.dp)
                        .background(
                            if (selected) EmbyGreen else foreground.value.copy(alpha = 0.22f),
                            CircleShape,
                        ),
                    contentAlignment = Alignment.Center,
                ) {
                    Text(
                        avatarInitials,
                        color = if (selected) Color.White else foreground.value,
                        fontSize = 9.sp,
                        fontWeight = FontWeight.Bold,
                    )
                }
            } else {
                Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
            }
            if (selected) {
                Box(
                    Modifier
                        .align(Alignment.CenterStart)
                        .width(2.dp)
                        .height(18.dp)
                        .background(EmbyGreen, RoundedCornerShape(2.dp)),
                )
            }
            // Over the icon rather than after the label: the rail spends most of its life
            // collapsed to 54dp, where there is no label to sit beside.
            if (badge != null) {
                Text(
                    badge,
                    color = Color.White,
                    fontSize = 9.sp,
                    fontWeight = FontWeight.Bold,
                    modifier = Modifier
                        .align(Alignment.TopEnd)
                        .background(AlertBadgeRed, CircleShape)
                        .padding(horizontal = 4.dp, vertical = 1.dp),
                )
            }
        }
        if (expanded) {
            Text(
                destination.label,
                color = foreground.value,
                fontSize = 15.sp,
                fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
                maxLines = 1,
            )
        }
    }
}

/** Two readable initials for compact profile avatars: AlwynV → AV, Matt Cohen → MC. */
internal fun profileInitials(username: String): String {
    val trimmed = username.trim()
    if (trimmed.isEmpty()) return "?"
    val words = trimmed.split(Regex("\\s+")).filter(String::isNotBlank)
    if (words.size > 1) {
        return "${words.first().first()}${words.last().first()}".uppercase()
    }
    val capitals = trimmed.filter(Char::isUpperCase)
    return when {
        capitals.length >= 2 -> capitals.take(2).uppercase()
        trimmed.length >= 2 -> trimmed.take(2).uppercase()
        else -> trimmed.uppercase()
    }
}

@Composable
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val repo = ServiceLocator.repository
    val imageUrl = item?.let { repo.backdropUrl(it, 1280) ?: repo.primaryUrl(it, 960) }
    var displayedUrl by remember { mutableStateOf(imageUrl) }
    LaunchedEffect(imageUrl) {
        if (displayedUrl == null) {
            displayedUrl = imageUrl
        } else {
            delay(BACKDROP_SETTLE_DELAY_MS)
            displayedUrl = imageUrl
        }
    }
    val request = remember(displayedUrl, context) {
        displayedUrl?.let {
            ImageRequest.Builder(context)
                .data(it)
                .size(1280, 720)
                .allowHardware(true)
                .crossfade(false)
                .build()
        }
    }
    Box(modifier.background(MembySurface)) {
        if (request != null) {
            AsyncImage(
                model = request,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier.fillMaxSize(),
            )
        }
        Box(
            Modifier.fillMaxSize().background(
                Brush.horizontalGradient(
                    0f to MembySurface,
                    0.58f to MembySurface.copy(alpha = 0.89f),
                    1f to MembySurface.copy(alpha = 0.65f),
                ),
            ),
        )
        Box(
            Modifier.fillMaxSize().background(
                Brush.verticalGradient(
                    0f to MembySurface.copy(alpha = 0.45f),
                    0.66f to MembySurface.copy(alpha = 0.84f),
                    1f to MembySurface,
                ),
            ),
        )
    }
}

@Composable
fun MediaMetadataPanel(
    item: BaseItem?,
    loading: Boolean,
    sectionLabel: String,
    modifier: Modifier = Modifier,
) {
    BoxWithConstraints(modifier) {
        val compactLayout = maxHeight < 260.dp
        val contentWidth = metadataPanelContentWidth(maxWidth, compactLayout)
        Column(Modifier.fillMaxSize()) {
            Box(Modifier.fillMaxSize().fillMaxWidth()) {
                when {
                    item == null && loading -> MetadataLoadingSkeleton(contentWidth)
                    item == null -> {
                        Column(
                            modifier = Modifier.width(contentWidth),
                            verticalArrangement = Arrangement.spacedBy(if (compactLayout) 7.dp else 10.dp),
                        ) {
                            Text(sectionLabel, color = QuietText, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
                            Text(
                                "Your library",
                                color = Color.White,
                                fontSize = if (compactLayout) 25.sp else 29.sp,
                                fontWeight = FontWeight.SemiBold,
                            )
                            Text("Choose something to watch.", color = MutedText, fontSize = 14.sp)
                        }
                    }
                    else -> MetadataContent(item, sectionLabel, contentWidth, compactLayout)
                }
            }
        }
    }
}

internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp =
    (availableWidth * if (compact) 0.84f else 0.76f)
        .coerceIn(280.dp, 720.dp)
        .coerceAtMost(availableWidth)

@Composable
private fun MetadataLoadingSkeleton(contentWidth: Dp) {
    Column(
        modifier = Modifier.width(contentWidth),
        verticalArrangement = Arrangement.spacedBy(11.dp),
    ) {
        SkeletonBlock(contentWidth * 0.18f, 11.dp)
        SkeletonBlock(contentWidth * 0.62f, 30.dp)
        SkeletonBlock(contentWidth * 0.46f, 14.dp)
        Spacer(Modifier.height(2.dp))
        SkeletonBlock(contentWidth, 13.dp)
        SkeletonBlock(contentWidth * 0.86f, 13.dp)
    }
}

@Composable
private fun SkeletonBlock(width: Dp, height: Dp) {
    Box(
        Modifier
            .width(width)
            .height(height)
            .clip(RoundedCornerShape(4.dp))
            .background(MembyControlSurfaceRaised),
    )
}

@Composable
fun MediaQuickActionsOverlay(
    item: BaseItem,
    onOpenDetails: (BaseItem) -> Unit,
    onSetFavorite: (BaseItem, Boolean) -> Unit,
    onSetPlayed: (BaseItem, Boolean) -> Unit,
    onRemoveFromContinueWatching: (() -> Unit)? = null,
    rowTitle: String? = null,
    rowPinned: Boolean = false,
    onToggleRowPinned: (() -> Unit)? = null,
    onHideRow: (() -> Unit)? = null,
    onMoveRow: ((Int) -> Unit)? = null,
    onClose: () -> Unit,
) {
    val hasRowActions = rowTitle != null &&
        onToggleRowPinned != null &&
        onHideRow != null &&
        onMoveRow != null
    val rowActionStartIndex = 3 + if (onRemoveFromContinueWatching != null) 1 else 0
    val actionCount = 4 + (if (onRemoveFromContinueWatching != null) 1 else 0) +
        (if (hasRowActions) 4 else 0)
    val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } }
    var focusedIndex by remember(item.id) { mutableStateOf(0) }
    // The menu can appear while OK is still physically held. Until that opening press
    // is released, consume all activation events so it cannot trigger the first action.
    var openingPressReleased by remember(item.id) { mutableStateOf(false) }
    LaunchedEffect(item.id) {
        delay(16)
        runCatching { focusRequesters.first().requestFocus() }
    }
    Box(
        Modifier
            .fillMaxSize()
            .zIndex(20f)
            .background(Color.Black.copy(alpha = 0.58f)),
    ) {
        Column(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(bottom = 32.dp)
                .width(352.dp)
                .shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
                .clip(RoundedCornerShape(MembyPanelCorner))
                .background(MembySurface)
                .border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
                .onPreviewKeyEvent { event ->
                    val native = event.nativeKeyEvent
                    val activationKey =
                        native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
                            native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
                            native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
                    if (activationKey && !openingPressReleased) {
                        if (native.action == android.view.KeyEvent.ACTION_UP) {
                            openingPressReleased = true
                        }
                        return@onPreviewKeyEvent true
                    }
                    if (event.type != KeyEventType.KeyDown) {
                        return@onPreviewKeyEvent false
                    }
                    when (event.key) {
                        Key.DirectionUp, Key.DirectionDown -> {
                            val direction = if (event.key == Key.DirectionUp) {
                                QuickActionDirection.UP
                            } else {
                                QuickActionDirection.DOWN
                            }
                            focusedIndex = quickActionNextIndex(
                                currentIndex = focusedIndex,
                                actionCount = actionCount,
                                direction = direction,
                            )
                            runCatching { focusRequesters[focusedIndex].requestFocus() }
                            true
                        }
                        Key.DirectionLeft, Key.Back -> {
                            onClose()
                            true
                        }
                        Key.DirectionRight -> true
                        else -> false
                    }
                }
                .padding(10.dp),
        ) {
            Text(
                "Quick actions",
                color = Color.White,
                fontSize = 18.sp,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
            )
            Text(
                item.name,
                color = QuietText,
                fontSize = 12.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
            )
            QuickActionMenuItem(
                label = "View details",
                icon = Icons.Default.Info,
                modifier = Modifier
                    .focusRequester(focusRequesters[0])
                    .onFocusChanged { if (it.isFocused) focusedIndex = 0 },
                onClick = { onOpenDetails(item) },
            )
            Spacer(Modifier.height(2.dp))
            QuickActionMenuItem(
                label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
                icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
                modifier = Modifier
                    .focusRequester(focusRequesters[1])
                    .onFocusChanged { if (it.isFocused) focusedIndex = 1 },
                onClick = {
                    onSetFavorite(item, !item.isFavorite)
                    onClose()
                },
            )
            Spacer(Modifier.height(2.dp))
            QuickActionMenuItem(
                label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
                icon = Icons.Default.CheckCircle,
                modifier = Modifier
                    .focusRequester(focusRequesters[2])
                    .onFocusChanged { if (it.isFocused) focusedIndex = 2 },
                onClick = {
                    onSetPlayed(item, item.userData?.played != true)
                    onClose()
                },
            )
            if (onRemoveFromContinueWatching != null) {
                Spacer(Modifier.height(2.dp))
                QuickActionMenuItem(
                    label = "Remove from Continue Watching",
                    icon = Icons.Default.PlaylistRemove,
                    modifier = Modifier
                        .focusRequester(focusRequesters[3])
                        .onFocusChanged { if (it.isFocused) focusedIndex = 3 },
                    onClick = onRemoveFromContinueWatching,
                )
            }
            if (hasRowActions) {
                Spacer(Modifier.height(6.dp))
                Box(
                    Modifier
                        .fillMaxWidth()
                        .height(1.dp)
                        .background(Color.White.copy(alpha = 0.07f)),
                )
                Text(
                    rowTitle.orEmpty(),
                    color = QuietText,
                    fontSize = 11.sp,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
                )
                QuickActionMenuItem(
                    label = if (rowPinned) "Unpin row" else "Pin row to top",
                    icon = Icons.Default.PushPin,
                    modifier = Modifier
                        .focusRequester(focusRequesters[rowActionStartIndex])
                        .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex },
                    onClick = onToggleRowPinned!!,
                )
                QuickActionMenuItem(
                    label = "Move row up",
                    icon = Icons.Default.ArrowUpward,
                    modifier = Modifier
                        .focusRequester(focusRequesters[rowActionStartIndex + 1])
                        .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 },
                    onClick = { onMoveRow!!(-1) },
                )
                QuickActionMenuItem(
                    label = "Move row down",
                    icon = Icons.Default.ArrowDownward,
                    modifier = Modifier
                        .focusRequester(focusRequesters[rowActionStartIndex + 2])
                        .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 },
                    onClick = { onMoveRow!!(1) },
                )
                QuickActionMenuItem(
                    label = "Hide this row",
                    icon = Icons.Default.VisibilityOff,
                    modifier = Modifier
                        .focusRequester(focusRequesters[rowActionStartIndex + 3])
                        .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 },
                    onClick = onHideRow!!,
                )
            }
            Spacer(Modifier.height(6.dp))
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(1.dp)
                    .background(Color.White.copy(alpha = 0.07f)),
            )
            Spacer(Modifier.height(6.dp))
            QuickActionMenuItem(
                label = "Close",
                icon = Icons.Default.ChevronLeft,
                modifier = Modifier
                    .focusRequester(focusRequesters[actionCount - 1])
                    .onFocusChanged { if (it.isFocused) focusedIndex = actionCount - 1 },
                onClick = onClose,
            )
        }
    }
}

@Composable
private fun QuickActionMenuItem(
    label: String,
    icon: ImageVector,
    modifier: Modifier = Modifier,
    onClick: () -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(42.dp)
            .onFocusChanged { focused = it.isFocused }
            .clip(RoundedCornerShape(MembyChipCorner))
            .background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
            .clickable(onClick = onClick)
            .semantics { contentDescription = label }
            .padding(horizontal = 12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(
            icon,
            contentDescription = null,
            tint = if (focused) Color.White else QuietText,
            modifier = Modifier.size(17.dp),
        )
        Spacer(Modifier.width(12.dp))
        Text(
            label,
            color = if (focused) Color.White else MutedText,
            fontSize = 14.sp,
            fontWeight = FontWeight.SemiBold,
        )
    }
}

@Composable
private fun MetadataContent(
    item: BaseItem,
    sectionLabel: String,
    contentWidth: Dp,
    compact: Boolean,
) {
    if (item.isSchedule) {
        ScheduleMetadataContent(item, sectionLabel, contentWidth, compact)
        return
    }
    Column(
        modifier = Modifier.width(contentWidth).fillMaxHeight(),
        verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp),
    ) {
        Text(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.1.sp)
        Text(
            item.seriesName?.takeIf { item.isEpisode } ?: item.name,
            color = Color.White,
            fontSize = if (compact) 25.sp else 29.sp,
            lineHeight = if (compact) 28.sp else 32.sp,
            fontWeight = FontWeight.SemiBold,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        if (item.isEpisode && item.seriesName != null) {
            Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
        }
        val facts = buildList {
            item.episodeCode?.let(::add)
            item.productionYear?.let { add(it.toString()) }
            item.runtimeMinutes?.let { add(formatRuntime(it)) }
            item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
            item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) }
        }
        // No score on this line. Emby's community rating stood beside these facts in gold
        // with nothing saying whose number it was; the strip below says that for every
        // score it shows, and two ratings in one panel only invited the comparison.
        if (facts.isNotEmpty()) {
            Text(
                facts.joinToString(FactSeparator),
                color = MutedText,
                fontSize = 13.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        ItemRatingsStrip(
            item = item,
            load = true,
            compact = compact,
            modifier = Modifier.fillMaxWidth(),
        )
        val badges = buildList {
            airingBadgeLabel(item)?.let(::add)
            addAll(mediaBadges(item))
        }
        item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
            Text(
                text = it,
                color = EmbyGreen,
                fontSize = 13.sp,
                fontWeight = FontWeight.SemiBold,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.fillMaxWidth(),
            )
        }
        if (badges.isNotEmpty()) {
            Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
                badges.forEach { MediaBadge(it) }
            }
        }
        Text(
            item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
            color = MembyMutedText,
            fontSize = 14.sp,
            lineHeight = 18.sp,
            maxLines = if (compact) 2 else 3,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.fillMaxWidth(),
        )
        MetadataStatus(item)
    }
}

@Composable
private fun ScheduleMetadataContent(
    item: BaseItem,
    sectionLabel: String,
    contentWidth: Dp,
    compact: Boolean,
) {
    Column(
        modifier = Modifier.width(contentWidth).fillMaxHeight(),
        verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp),
    ) {
        Text(
            sectionLabel.uppercase(),
            color = EmbyGreen,
            fontSize = 11.sp,
            fontWeight = FontWeight.Bold,
            letterSpacing = 1.2.sp,
        )
        Text(
            item.name,
            color = Color.White,
            fontSize = if (compact) 25.sp else 29.sp,
            lineHeight = if (compact) 28.sp else 32.sp,
            fontWeight = FontWeight.SemiBold,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        if (item.isTvSchedule) {
            Text(
                listOfNotNull(
                    item.membyEpisodeCode,
                    item.membyEpisodeTitle?.takeIf(String::isNotBlank),
                ).joinToString(FactSeparator),
                color = MutedText,
                fontSize = 14.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        } else {
            Text(
                listOfNotNull(
                    item.productionYear?.toString(),
                    item.runtimeMinutes?.let(::formatRuntime),
                    item.genres.take(2).joinToString(ValueSeparator).takeIf(String::isNotBlank),
                ).joinToString(FactSeparator),
                color = MutedText,
                fontSize = 14.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            item.membyAirLabel?.takeIf(String::isNotBlank)?.let { MediaBadge(it.uppercase()) }
            item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let {
                MediaBadge(it.uppercase())
            }
        }
        Text(
            item.overview?.takeIf(String::isNotBlank)
                ?: if (item.isMovieSchedule) {
                    "Movie details will appear when they become available."
                } else {
                    "Episode details will appear when they become available."
                },
            color = MembyMutedText,
            fontSize = 14.sp,
            lineHeight = 18.sp,
            maxLines = if (compact) 2 else 3,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.fillMaxWidth(),
        )
        Text(
            if (item.isMovieSchedule) "Upcoming digital releases" else "TV this week",
            color = QuietText,
            fontSize = 12.sp,
        )
    }
}

@Composable
private fun MetadataStatus(item: BaseItem) {
    val position = item.userData?.playbackPositionTicks ?: 0L
    val runtime = item.runTimeTicks ?: 0L
    val progress = if (runtime > 0) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
    Row(
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        if (progress > 0f) {
            Box(
                Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)),
            ) {
                Box(Modifier.fillMaxWidth(progress).height(4.dp).background(EmbyGreen, RoundedCornerShape(2.dp)))
            }
            Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp)
        }
        if (item.userData?.played == true) {
            Icon(Icons.Default.CheckCircle, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
            Text("Watched", color = MutedText, fontSize = 13.sp)
        }
        if (item.isFavorite) {
            Icon(Icons.Default.Favorite, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp))
            Text("Favourite", color = MutedText, fontSize = 13.sp)
        }
    }
}

/**
 * The 28dp icon chip every home row header leads with, and the gap after it.
 *
 * Extracted because a header that skipped the chip started its title 38dp further left than
 * the rows above and below it. The launcher's row titles are read as one column, and one
 * that steps sideways breaks the column.
 */
internal val HomeRowHeaderIconGap = 10.dp

/** Vertical gap between a row's header and its cards. One value for every row. */
internal val HomeRowHeaderSpacing = 6.dp

@Composable
internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .size(28.dp)
            .clip(CircleShape)
            .background(Color.White.copy(alpha = 0.08f))
            .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            imageVector = icon,
            contentDescription = null,
            tint = Color.White,
            modifier = Modifier.size(15.dp),
        )
    }
}

@Composable
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
    Text(
        label,
        color = MembyOnSurface,
        fontSize = 11.sp,
        fontWeight = FontWeight.Bold,
        modifier = modifier
            .border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp))
            .background(Color.Black.copy(alpha = 0.22f), RoundedCornerShape(4.dp))
            .padding(horizontal = 6.dp, vertical = 2.dp),
    )
}

internal fun mediaBadges(item: BaseItem): List<String> {
    val video = item.mediaStreams.firstOrNull { it.type.equals("Video", true) }
    val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", true) }
    return buildList {
        if ((video?.width ?: 0) >= UHD_MIN_WIDTH) add("4K")
        // Same reading of the streams as the detail page's spec row, so a file cannot be
        // HDR10+ in one place and plain HDR in the other.
        dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
            ?.let { add(it.uppercase(Locale.US)) }
        if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC")
        audio?.channels?.takeIf { it > 0 }?.let { add(channelLabel(it).uppercase(Locale.US)) }
        if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS")
    }.distinct()
}

@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
internal fun MediaRow(
    row: HomeBrowseRow,
    availableWidth: Dp,
    navigationFocusRequester: FocusRequester,
    contentEntryFocusRequester: FocusRequester?,
    heroEntryFocusRequester: FocusRequester? = null,
    returnFocusItemId: String?,
    returnFocusRequester: FocusRequester,
    verticalFocusRequest: RowFocusRequest?,
    onVerticalFocusRequestConsumed: (Int) -> Unit,
    onMoveVertical: (itemIndex: Int, direction: RowFocusDirection) -> Boolean,
    onContentFocused: () -> Unit,
    onItemFocused: (BaseItem) -> Unit,
    onItemSelected: (BaseItem) -> Unit,
    onItemLongPressed: (BaseItem) -> Unit,
    density: String = "standard",
    artworkStyle: String = "automatic",
    horizontalState: LazyListState? = null,
    modifier: Modifier = Modifier,
) {
    val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
    val rowState = horizontalState ?: savedRowState
    val verticalEntryFocusRequester = remember { FocusRequester() }
    val scope = rememberCoroutineScope()
    val requestedEntryIndex = verticalFocusRequest
        ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
        ?.itemIndex
        ?.coerceIn(0, row.items.lastIndex)
    val currentOnVerticalFocusRequestConsumed by rememberUpdatedState(
        onVerticalFocusRequestConsumed,
    )
    LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) {
        val request = verticalFocusRequest
            ?.takeIf { it.rowId == row.id && requestedEntryIndex != null }
            ?: return@LaunchedEffect
        rowState.scrollToItem(requestedEntryIndex!!)
        // LazyRow applies scrollToItem during layout. Wait for the requested card's focus
        // node to attach before transferring focus; retrying covers slower TV frames. Six
        // attempts rather than three because giving up here is a press that visibly does
        // nothing, and the shelf this arrives at is commonly one being composed for the
        // first time — the frame budget on a Chromecast is not the one a warm row gets.
        repeat(6) {
            kotlinx.coroutines.delay(16L)
            if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) {
                currentOnVerticalFocusRequestConsumed(request.requestId)
                return@LaunchedEffect
            }
        }
        currentOnVerticalFocusRequestConsumed(request.requestId)
    }
    // firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
    // only the button's enabled/disabled flip recomposes the row header.
    val canScrollBack by remember(rowState) {
        derivedStateOf { rowState.firstVisibleItemIndex > 0 }
    }
    val canScrollForward by remember(rowState) {
        derivedStateOf { rowState.canScrollForward }
    }
    val pageSize = if (
        row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT
    ) 6 else 4
    Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
        Row(
            modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            val visual = homeRowVisual(row)
            HomeRowHeaderIcon(visual.icon)
            Spacer(Modifier.width(HomeRowHeaderIconGap))
            Text(
                row.title,
                color = MembyOnSurface,
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
            )
            Spacer(Modifier.weight(1f))
            if (row.items.isNotEmpty()) {
                GalleryJumpButton(
                    forward = false,
                    enabled = canScrollBack,
                    onClick = {
                        val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
                        scope.launch { rowState.scrollToItem(target) }
                    },
                )
                Spacer(Modifier.width(8.dp))
                GalleryJumpButton(
                    forward = true,
                    enabled = canScrollForward,
                    onClick = {
                        val target = (rowState.firstVisibleItemIndex + pageSize)
                            .coerceAtMost(row.items.lastIndex)
                        scope.launch { rowState.scrollToItem(target) }
                    },
                )
            }
        }
        when {
            row.items.isEmpty() && row.loading -> {
                LazyRow(
                    contentPadding = PaddingValues(horizontal = 36.dp, vertical = 9.dp),
                    horizontalArrangement = Arrangement.spacedBy(16.dp),
                ) {
                    items(5) {
                        TvLoadingPlaceholder(
                            portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS),
                            availableWidth = availableWidth,
                        )
                    }
                }
            }
            row.items.isEmpty() && row.id == "favourite-shows" -> {
                FavoriteShowsEmptyState(
                    navigationFocusRequester = navigationFocusRequester,
                    onContentFocused = onContentFocused,
                )
            }
            row.items.isEmpty() -> {
                Text(
                    row.emptyMessage,
                    color = QuietText,
                    fontSize = 14.sp,
                    modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
                )
            }
            else -> {
                LazyRow(
                    state = rowState,
                    contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
                    horizontalArrangement = Arrangement.spacedBy(16.dp),
                    // focusRestorer pins a lazy item. Replacing a For You result set
                    // can dispose that item during a focus transfer and make Compose
                    // release the same pin twice. LazyListState already preserves the
                    // row position; native TV spatial search safely handles row changes.
                    modifier = Modifier.fillMaxWidth().focusGroup(),
                ) {
                    itemsIndexed(
                        row.items,
                        key = { _, item -> item.id },
                        contentType = { _, item -> if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" },
                    ) { index, item ->
                        var cardModifier: Modifier = Modifier
                        if (index == 0) {
                            if (contentEntryFocusRequester != null) {
                                cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
                            }
                            // Where Down out of the home hero lands. It is a second
                            // requester rather than the entry one because while the hero is
                            // up, that one belongs to the hero.
                            if (heroEntryFocusRequester != null) {
                                cardModifier = cardModifier.focusRequester(heroEntryFocusRequester)
                            }
                        }
                        if (item.id == returnFocusItemId) {
                            cardModifier = cardModifier.focusRequester(returnFocusRequester)
                        }
                        if (index == requestedEntryIndex) {
                            cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
                        }
                        cardModifier = cardModifier.onPreviewKeyEvent { event ->
                            if (event.type != KeyEventType.KeyDown) {
                                return@onPreviewKeyEvent false
                            }
                            when (event.key) {
                                Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP)
                                Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
                                else -> false
                            }
                        }
                        val focused: () -> Unit = {
                            onContentFocused()
                            onItemFocused(item)
                        }
                        when (cardFormat(row.kind, item, artworkStyle)) {
                            // The resume bar belongs to the row, not to the card's shape: a
                            // household that has set artwork to posters still needs to see
                            // how far into something it is.
                            MediaCardFormat.PORTRAIT -> PortraitMediaCard(
                                item, availableWidth, row.showSecondaryMetadata, focused,
                                { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
                                density, row.showWatchedEpisodeCount,
                                showProgress = row.kind == MediaRowKind.CONTINUE,
                            )
                            MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) {
                                ContinueWatchingCard(
                                    item, availableWidth, row.showSecondaryMetadata, focused,
                                    { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
                                    density, row.showWatchedEpisodeCount,
                                )
                            } else {
                                LandscapeMediaCard(
                                    item, availableWidth, row.showSecondaryMetadata, focused,
                                    { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
                                    density, row.showWatchedEpisodeCount,
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
internal fun CastRail(
    people: List<EmbyPerson>,
    modifier: Modifier = Modifier,
    compact: Boolean = false,
    showTitle: Boolean = true,
) {
    val distinctCast = remember(people) {
        people.filter(EmbyPerson::isCastMember)
            .distinctForKeys { "${it.id}:${it.name}" }
            .take(16)
    }
    if (distinctCast.isEmpty()) return
    Column(modifier) {
        if (showTitle) {
            Text(
                "Cast",
                color = Color.White,
                fontSize = if (compact) 16.sp else 21.sp,
                fontWeight = FontWeight.SemiBold,
            )
        }
        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 16.dp),
            contentPadding = PaddingValues(
                top = if (showTitle) 7.dp else 3.dp,
                end = 24.dp,
                bottom = 4.dp,
            ),
        ) {
            // Emby lists the same person twice often enough that this is not a theoretical
            // guard: a title where one actor is credited under two roles, or where an
            // agent has written the cast twice, would otherwise hand this LazyRow two
            // items under one key and take the whole detail page down with it.
            items(distinctCast, key = { "${it.id}:${it.name}" }) { person ->
                CastCard(person, compact)
            }
        }
    }
}

@Composable
private fun CastCard(person: EmbyPerson, compact: Boolean) {
    val repository = ServiceLocator.repository
    val portrait = remember(person.id, person.primaryImageTag) {
        repository.personImageUrl(person, if (compact) 180 else 280)
    }
    var focused by remember { mutableStateOf(false) }
    val width = if (compact) 72.dp else 112.dp
    val height = if (compact) 76.dp else 142.dp
    val shape = RoundedCornerShape(MembyCardCorner)
    val scale by animateFloatAsState(
        targetValue = if (focused) 1.045f else 1f,
        animationSpec = tween(110),
        label = "cast-card-focus",
    )
    Column(
        Modifier
            .width(width)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
            .onFocusChanged { focused = it.isFocused }
            .focusable(),
    ) {
        Box(
            Modifier
                .fillMaxWidth()
                .height(height)
                .clip(shape)
                .background(MembyControlSurface)
                .border(
                    if (focused) 2.dp else 1.dp,
                    if (focused) Color.White else Color.White.copy(alpha = 0.10f),
                    shape,
                ),
            contentAlignment = Alignment.Center,
        ) {
            if (portrait != null) {
                AsyncImage(
                    model = portrait,
                    contentDescription = person.name,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier.fillMaxSize(),
                )
            } else {
                Icon(
                    Icons.Default.Person,
                    contentDescription = null,
                    tint = QuietText,
                    modifier = Modifier.size(if (compact) 28.dp else 40.dp),
                )
            }
        }
        Text(
            person.name,
            color = Color.White,
            fontSize = if (compact) 11.sp else 14.sp,
            fontWeight = FontWeight.Medium,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.padding(top = 5.dp),
        )
        person.role?.takeIf(String::isNotBlank)?.let { role ->
            Text(
                role,
                color = QuietText,
                fontSize = if (compact) 9.sp else 11.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

@Composable
private fun FavoriteShowsEmptyState(
    navigationFocusRequester: FocusRequester,
    onContentFocused: () -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 36.dp, vertical = 10.dp)
            .clip(RoundedCornerShape(MembyPanelCorner))
            .background(Color.White.copy(alpha = if (focused) 0.11f else 0.055f))
            .border(
                2.dp,
                if (focused) Color.White else Color.White.copy(alpha = 0.14f),
                RoundedCornerShape(MembyPanelCorner),
            )
            .focusProperties { left = navigationFocusRequester }
            .onFocusChanged {
                focused = it.isFocused
                if (it.isFocused) onContentFocused()
            }
            .focusable()
            .padding(horizontal = 22.dp, vertical = 20.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(18.dp),
    ) {
        Box(
            modifier = Modifier
                .size(46.dp)
                .clip(CircleShape)
                .background(Color.White.copy(alpha = 0.09f)),
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                imageVector = Icons.Default.FavoriteBorder,
                contentDescription = null,
                tint = Color.White,
                modifier = Modifier.size(25.dp),
            )
        }
        Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
            Text(
                "No favourite shows yet",
                color = Color.White,
                fontSize = 17.sp,
                fontWeight = FontWeight.Bold,
            )
            Text(
                "Mark a series as a favourite and it’ll be waiting here.",
                color = MutedText,
                fontSize = 14.sp,
                fontWeight = FontWeight.Medium,
            )
        }
    }
}

@Composable
private fun GalleryJumpButton(
    forward: Boolean,
    enabled: Boolean,
    onClick: () -> Unit,
) {
    FocusScaleContainer(
        onFocused = {},
        onClick = { if (enabled) onClick() },
        onLongClick = null,
        contentDescription = if (forward) "Next page" else "Previous page",
        // These mirror remote left/right paging for pointer users. Keeping them out of
        // the focus graph prevents Up from a card landing in a tiny header control.
        modifier = Modifier
            .width(42.dp)
            .height(36.dp)
            .focusProperties { canFocus = false },
    ) { focused ->
        Box(
            Modifier
                .fillMaxSize()
                .clip(RoundedCornerShape(MembyChipCorner))
                .background(
                    when {
                        focused -> Color.White.copy(alpha = 0.16f)
                        enabled -> Color.White.copy(alpha = 0.08f)
                        else -> Color.White.copy(alpha = 0.03f)
                    },
                ),
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                imageVector = if (forward) Icons.Default.ChevronRight else Icons.Default.ChevronLeft,
                contentDescription = null,
                tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f),
                modifier = Modifier.size(23.dp),
            )
        }
    }
}

private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }

private val MediaRowHorizontalInset = 36.dp
private val MediaRowCardSpacing = 16.dp

/**
 * Sizes a shelf to an integer number of complete cards.
 *
 * The old fractional "cards across" values intentionally left the next card hanging
 * outside the viewport. Besides looking clipped, that became more pronounced on smaller
 * TVs and whenever the navigation rail reduced the content width. Work from the actual
 * row width (including its padding and gaps) so every visible card has a complete edge.
 */
internal fun responsiveRowCardWidth(
    availableWidth: Dp,
    preferredCardsAcross: Int,
    minWidth: Dp,
    maxWidth: Dp,
    horizontalInset: Dp = MediaRowHorizontalInset,
    spacing: Dp = MediaRowCardSpacing,
): Dp {
    val usableWidth = (availableWidth - horizontalInset * 2).coerceAtLeast(1.dp)
    val cardsThatFitMinimum =
        ((usableWidth + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1)
    var cardCount = preferredCardsAcross.coerceIn(1, cardsThatFitMinimum)

    // Very wide viewports should gain another complete card instead of a large dead area.
    while (cardCount < cardsThatFitMinimum) {
        val candidate = (usableWidth - spacing * (cardCount - 1)) / cardCount
        if (candidate <= maxWidth) break
        cardCount += 1
    }

    return ((usableWidth - spacing * (cardCount - 1)) / cardCount)
        .coerceAtMost(maxWidth)
        .coerceAtLeast(1.dp)
}

private fun cardFormat(
    kind: MediaRowKind,
    item: BaseItem,
    artworkStyle: String = "automatic",
): MediaCardFormat = when {
    artworkStyle == "poster" -> MediaCardFormat.PORTRAIT
    artworkStyle == "backdrop" -> MediaCardFormat.LANDSCAPE
    kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
    item.isEpisode -> MediaCardFormat.LANDSCAPE
    else -> MediaCardFormat.PORTRAIT
}

@Composable
fun PortraitMediaCard(
    item: BaseItem,
    availableWidth: Dp,
    showSecondaryMetadata: Boolean,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    onLongClick: () -> Unit,
    modifier: Modifier = Modifier,
    density: String = "standard",
    showWatchedEpisodeCount: Boolean = false,
    showProgress: Boolean = false,
) {
    val cardsAcross = when (density) {
        "compact" -> 8
        "large" -> 5
        else -> 7
    }
    val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
    MediaCard(
        item, width, 2f / 3f, preferPrimary = true, showProgress = showProgress,
        showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
    )
}

/**
 * A poster card at an explicit width, for grids rather than rows.
 *
 * [PortraitMediaCard] derives its width from the row's available width, which is the
 * wrong input for a grid whose column count is already decided. Same card underneath —
 * same artwork loading, badges and focus treatment.
 */
@Composable
fun PosterGridCard(
    item: BaseItem,
    width: Dp,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    onLongClick: () -> Unit,
    modifier: Modifier = Modifier,
    showWatchedEpisodeCount: Boolean = false,
) {
    MediaCard(
        item, width, 2f / 3f, preferPrimary = true, showProgress = false,
        showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
    )
}

@Composable
fun LandscapeMediaCard(
    item: BaseItem,
    availableWidth: Dp,
    showSecondaryMetadata: Boolean,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    onLongClick: () -> Unit,
    modifier: Modifier = Modifier,
    density: String = "standard",
    showWatchedEpisodeCount: Boolean = false,
) {
    val cardsAcross = when (density) {
        "compact" -> 5
        "large" -> 3
        else -> 4
    }
    val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
    MediaCard(
        item, width, 16f / 9f, preferPrimary = false, showProgress = false,
        showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
    )
}

@Composable
fun ContinueWatchingCard(
    item: BaseItem,
    availableWidth: Dp,
    showSecondaryMetadata: Boolean,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    onLongClick: () -> Unit,
    modifier: Modifier = Modifier,
    density: String = "standard",
    showWatchedEpisodeCount: Boolean = false,
) {
    val cardsAcross = when (density) {
        "compact" -> 5
        "large" -> 3
        else -> 4
    }
    val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
    MediaCard(
        item, width, 16f / 9f, preferPrimary = false, showProgress = true,
        showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
    )
}

@Composable
private fun MediaCard(
    item: BaseItem,
    width: Dp,
    aspectRatio: Float,
    preferPrimary: Boolean,
    showProgress: Boolean,
    showSecondaryMetadata: Boolean,
    showWatchedEpisodeCount: Boolean,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    onLongClick: () -> Unit,
    modifier: Modifier,
) {
    val repo = ServiceLocator.repository
    val density = LocalDensity.current
    val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
    val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1)
    val image = remember(item.id, widthPx, preferPrimary) {
        if (preferPrimary) {
            repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit }
                ?: repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop }
        } else {
            repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop }
                ?: repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit }
        }
    }
    val imageUrl = image?.first
    val position = item.userData?.playbackPositionTicks ?: 0L
    val runtime = item.runTimeTicks ?: 0L
    val progress = if (runtime > 0L) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
    var failed by remember(item.id, imageUrl) { mutableStateOf(false) }
    var loading by remember(item.id, imageUrl) { mutableStateOf(imageUrl != null) }
    val context = LocalContext.current
    val imageRequest = remember(imageUrl, widthPx, heightPx, context) {
        imageUrl?.let {
            ImageRequest.Builder(context)
                .data(it)
                .size(widthPx, heightPx)
                .allowHardware(true)
                .crossfade(false)
                .build()
        }
    }
    FocusScaleContainer(
        onFocused = onFocused,
        onClick = onClick,
        onLongClick = onLongClick,
        contentDescription = cardDescription(item, progress, showWatchedEpisodeCount),
        modifier = modifier.width(width),
    ) { focused ->
        Column {
            Box(
                Modifier
                    .width(width)
                    .aspectRatio(aspectRatio)
                    // Keep the modifier node stable while focus changes. Adding/removing
                    // the shadow node forces the poster subtree to be rebuilt exactly
                    // when a row is moving into view, which shows up as a small hitch on
                    // lower-powered TV hardware.
                    .shadow(
                        elevation = if (focused) 7.dp else 0.dp,
                        shape = RoundedCornerShape(MembyCardCorner),
                    )
                    .clip(RoundedCornerShape(MembyCardCorner))
                    .background(MembySurfaceRaised)
                    .border(
                        2.dp,
                        if (focused) Color.White else Color.White.copy(alpha = 0.07f),
                        RoundedCornerShape(MembyCardCorner),
                    ),
                contentAlignment = Alignment.Center,
            ) {
                if (loading) {
                    ArtworkLoadingSkeleton(Modifier.fillMaxSize())
                }
                if (imageRequest != null) {
                    AsyncImage(
                        model = imageRequest,
                        contentDescription = null,
                        contentScale = image?.second ?: ContentScale.Crop,
                        onLoading = { loading = true },
                        onSuccess = {
                            failed = false
                            loading = false
                        },
                        onError = {
                            failed = true
                            loading = false
                        },
                        modifier = Modifier.fillMaxSize(),
                    )
                }
                if (imageUrl == null || failed) {
                    Icon(
                        Icons.Default.BrokenImage,
                        contentDescription = "Artwork unavailable",
                        tint = QuietText,
                        modifier = Modifier.size(30.dp),
                    )
                }
                if (showProgress && progress > 0f) {
                    Box(
                        Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(5.dp)
                            .background(Color.Black.copy(alpha = 0.65f)),
                    ) {
                        Box(Modifier.fillMaxWidth(progress).height(5.dp).background(EmbyGreen))
                    }
                }
                if (item.userData?.played == true || item.isFavorite) {
                    Column(
                        modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
                        verticalArrangement = Arrangement.spacedBy(6.dp),
                    ) {
                        if (item.userData?.played == true) {
                            MediaStatusIcon(
                                icon = Icons.Default.CheckCircle,
                                description = "Watched",
                                tint = EmbyGreen,
                            )
                        }
                        if (item.isFavorite) {
                            MediaStatusIcon(
                                icon = Icons.Default.Favorite,
                                description = "Favourite",
                                tint = Color(0xFFFF6B81),
                            )
                        }
                    }
                }
                if (item.isSchedule) {
                    scheduleStatusBadgeLabel(item.membyAvailability.orEmpty())?.let {
                        ScheduleStatusBadge(
                            status = item.membyAvailability.orEmpty(),
                            label = it,
                            modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
                        )
                    }
                    // Bottom-left, because the two top corners already carry the air day and
                    // this episode's availability, and the progress bar owns the bottom edge
                    // only on cards that can be resumed — which a schedule card cannot.
                    lifecycleBadgeLabel(item)?.let {
                        LifecycleBadge(
                            status = item.membyLifecycle.orEmpty(),
                            label = it,
                            modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
                        )
                    }
                }
                airingBadgeLabel(item)?.let { label ->
                    MediaBadge(
                        label,
                        modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
                    )
                }
            }
            Text(
                item.name,
                color = if (focused) Color.White else MembyOnSurface,
                fontSize = 14.sp,
                fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
            )
            if (showSecondaryMetadata) {
                Text(
                    cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
                    color = QuietText,
                    fontSize = 12.sp,
                    fontWeight = FontWeight.Medium,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
                )
            } else {
                Spacer(Modifier.height(3.dp))
            }
            // No ratings strip under a poster. The scores are on the detail page the card
            // opens and in the metadata panel beside the focused card; a third copy under
            // every poster in every row cost a line of height on cards that are already
            // two lines of text tall.
        }
    }
}

@Composable
private fun MediaStatusIcon(
    icon: ImageVector,
    description: String,
    tint: Color,
) {
    Box(
        modifier = Modifier
            .size(29.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(Color.Black.copy(alpha = 0.78f))
            .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(10.dp)),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            icon,
            contentDescription = description,
            tint = tint,
            modifier = Modifier.size(18.dp),
        )
    }
}

@Composable
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
    val color = when (status) {
        "available" -> EmbyGreen
        "downloading" -> Color(0xFF5DA9FF)
        "awaiting" -> Color(0xFFFFB454)
        "unmonitored" -> QuietText
        else -> MembyOnSurface
    }
    Text(
        text = label,
        color = if (status == "available") MembyAccentInk else MembySurface,
        fontSize = 9.sp,
        fontWeight = FontWeight.Bold,
        letterSpacing = 0.5.sp,
        modifier = modifier
            .clip(RoundedCornerShape(4.dp))
            .background(color)
            .padding(horizontal = 7.dp, vertical = 4.dp),
    )
}

internal fun scheduleStatusBadgeLabel(status: String): String? = when (status) {
    "available" -> "ADDED"
    "downloading" -> "DOWNLOADING"
    "awaiting" -> "AWAITING"
    "unmonitored" -> "UNMONITORED"
    // The day badge and air-time subtitle already say when a future episode airs.
    // Repeating "upcoming" adds no state and can collide with the more useful day.
    else -> null
}

/**
 * The show's or film's lifecycle, as Sonarr and Radarr word it — CONTINUING, ENDED, IN
 * CINEMAS. It answers a different question from the availability badge above it: that one
 * is about the household's copy of this episode, this one is about whether there will be
 * any more of them.
 *
 * The wording is the gateway's, taken verbatim off the card, so a status a build predates
 * still reads correctly rather than falling back to a slug. A card carrying no lifecycle —
 * an older gateway, a cached row, a show *arr has no status for — simply wears no tag.
 */
internal fun lifecycleBadgeLabel(item: BaseItem): String? =
    item.membyLifecycleText?.trim()?.takeIf(String::isNotEmpty)?.uppercase()

@Composable
internal fun LifecycleBadge(status: String, label: String, modifier: Modifier = Modifier) {
    // One colour per status, matching what the same word is coloured in *arr: still being
    // made is green, over is red, not out yet is blue, in cinemas is amber.
    val (background, foreground) = when (status) {
        "continuing", "released" -> EmbyGreen to MembyAccentInk
        "upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface
        "incinemas" -> Color(0xFFFFB454) to MembySurface
        "ended" -> Color(0xFFE04747) to Color.White
        else -> MembyControlSurfaceRaised to MembyOnSurface
    }
    Text(
        text = label,
        color = foreground,
        fontSize = 9.sp,
        fontWeight = FontWeight.Bold,
        letterSpacing = 0.5.sp,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
        modifier = modifier
            .clip(RoundedCornerShape(4.dp))
            .background(background)
            .padding(horizontal = 7.dp, vertical = 4.dp),
    )
}

@Composable
fun FocusScaleContainer(
    onFocused: () -> Unit,
    onClick: () -> Unit,
    contentDescription: String,
    modifier: Modifier = Modifier,
    onLongClick: (() -> Unit)? = null,
    content: @Composable BoxScope.(focused: Boolean) -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    val pressScope = rememberCoroutineScope()
    var holdJob by remember { mutableStateOf<Job?>(null) }
    var remoteLongPressHandled by remember { mutableStateOf(false) }
    val currentOnLongClick by rememberUpdatedState(onLongClick)
    val scale = animateFloatAsState(
        targetValue = if (focused) 1.025f else 1f,
        animationSpec = tween(95),
        label = "media-card-focus",
    )
    Box(
        modifier = modifier
            .zIndex(if (focused) 1f else 0f)
            // Always a graphicsLayer, and the scale read inside it. Branching on
            // `scale != 1f` meant reading the animated value during composition, so
            // every card recomposed on each frame of its own focus animation.
            .graphicsLayer {
                scaleX = scale.value
                scaleY = scale.value
            }
            .onFocusChanged {
                focused = it.isFocused
                if (it.isFocused) {
                    onFocused()
                } else {
                    holdJob?.cancel()
                    holdJob = null
                    remoteLongPressHandled = false
                }
            }
            .then(
                if (onLongClick != null) {
                    Modifier
                        .onPreviewKeyEvent { event ->
                            val native = event.nativeKeyEvent
                            val activationKey = native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
                                native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
                                native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
                            when {
                                activationKey &&
                                    native.action == android.view.KeyEvent.ACTION_DOWN &&
                                    native.repeatCount == 0 &&
                                    holdJob == null &&
                                    !remoteLongPressHandled -> {
                                    holdJob = pressScope.launch {
                                        delay(QuickActionsHoldDurationMillis)
                                        remoteLongPressHandled = true
                                        holdJob = null
                                        currentOnLongClick?.invoke()
                                    }
                                    true
                                }
                                activationKey &&
                                    native.action == android.view.KeyEvent.ACTION_DOWN -> true
                                activationKey &&
                                    native.action == android.view.KeyEvent.ACTION_UP -> {
                                    val wasLongPress = remoteLongPressHandled
                                    holdJob?.cancel()
                                    holdJob = null
                                    remoteLongPressHandled = false
                                    if (!wasLongPress) onClick()
                                    true
                                }
                                else -> false
                            }
                        }
                        .clickable(onClick = onClick)
                } else {
                    Modifier.clickable(onClick = onClick)
                },
            )
            .semantics(mergeDescendants = true) {
                this.contentDescription = contentDescription
                if (onLongClick != null) {
                    onLongClick("Quick actions") {
                        onLongClick()
                        true
                    }
                }
            },
        content = { content(focused) },
    )
}

@Composable
fun TvLoadingPlaceholder(
    portrait: Boolean,
    availableWidth: Dp,
    modifier: Modifier = Modifier,
) {
    val width = if (portrait) {
        responsiveRowCardWidth(availableWidth, 7, 102.dp, 218.dp)
    } else {
        responsiveRowCardWidth(availableWidth, 4, 164.dp, 360.dp)
    }
    Column(modifier.width(width)) {
        Box(
            Modifier
                .width(width)
                .aspectRatio(if (portrait) 2f / 3f else 16f / 9f)
                .clip(RoundedCornerShape(MembyCardCorner)),
        ) {
            ArtworkLoadingSkeleton(Modifier.fillMaxSize())
        }
        Spacer(Modifier.height(8.dp))
        SkeletonBlock(width * 0.72f, 11.dp)
        Spacer(Modifier.height(5.dp))
        SkeletonBlock(width * 0.48f, 8.dp)
    }
}

@Composable
private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
    Box(
        modifier.background(
            Brush.linearGradient(
                colors = listOf(
                    MembyControlSurface,
                    MembyControlSurfaceRaised,
                    MembyControlSurface,
                ),
            ),
        ),
    )
}

private const val BACKDROP_SETTLE_DELAY_MS = 240L

internal fun watchedEpisodeCountLabel(item: BaseItem): String? {
    if (!item.isSeries) return null
    val total = item.recursiveItemCount?.takeIf { it > 0 } ?: return null
    val unwatched = item.userData?.unplayedItemCount?.coerceIn(0, total) ?: return null
    val watched = total - unwatched
    return "$watched of $total watched"
}

private fun cardSubtitle(
    item: BaseItem,
    showProgress: Boolean,
    positionTicks: Long,
    showWatchedEpisodeCount: Boolean,
): String = when {
    showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
    showWatchedEpisodeCount -> watchedEpisodeCountLabel(item)
        ?: defaultCardSubtitle(item)
    else -> defaultCardSubtitle(item)
}

private fun defaultCardSubtitle(item: BaseItem): String = when {
    !item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason
    item.isSchedule -> item.membyAirLabel ?: "Coming up"
    item.isEpisode -> item.seriesName ?: "Up next"
    item.productionYear != null && item.runtimeMinutes != null ->
        "${item.productionYear}$FactSeparator${formatRuntime(requireNotNull(item.runtimeMinutes))}"
    item.productionYear != null -> item.productionYear.toString()
    item.isSeries -> "Series"
    else -> item.type
}

internal fun airingBadgeLabel(item: BaseItem): String? = when {
    item.isSchedule -> item.membyAirDayLabel
        ?.takeIf(String::isNotBlank)
        ?.uppercase()
    item.membyAiringToday -> "AIRING TODAY"
    else -> null
}

private fun cardDescription(
    item: BaseItem,
    progress: Float,
    showWatchedEpisodeCount: Boolean,
): String = buildString {
    append(item.name)
    item.seriesName?.let { append(", ").append(it) }
    if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
    if (item.userData?.played == true) append(", watched")
    if (showWatchedEpisodeCount) watchedEpisodeCountLabel(item)?.let { append(", ").append(it) }
    if (item.isFavorite) append(", favourite")
    if (item.isSchedule) {
        item.membyAirLabel?.let { append(", ").append(it) }
        item.membyAvailabilityText?.let { append(", ").append(it) }
        item.membyLifecycleText?.let { append(", ").append(it) }
    }
}

private fun formatTvPosition(ticks: Long): String {
    val minutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0)
    return formatRuntime(minutes)
}
