package com.ponzischeme89.memby.ui

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
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.platform.testTag
import androidx.compose.ui.semantics.contentDescription
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.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
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.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
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 kotlinx.coroutines.launch

// The detail page's names for the shared tokens. The neutrals used to be a shade darker
// here than on the launcher, which is visible the moment a page opens from a row.
//
// Each is a `get()` and must stay one: the tokens are snapshot state now that the palette
// comes from the server, and an alias that captured a value would pin this whole page to
// whichever theme was loaded when the class first initialised.
internal val DetailBackground: Color get() = MembySurface
internal val DetailAccent: Color get() = MembyAccent
internal val DetailText: Color get() = MembyOnSurface
internal val DetailMutedText: Color get() = MembyMutedText
internal val DetailQuietText: Color get() = MembyQuietText
internal val DetailHairline: Color get() = MembyHairline
internal val DetailSideGutter = 58.dp

/**
 * The band anchored under the hero: the tab strip on a movie or series page, the season
 * scroller on an episode's. One height for both, because the fold it defines is the same
 * fold, and a page whose content starts at a different place depending on what it is about
 * reads as two designs.
 */
internal val DetailStripHeight = 66.dp

/**
 * How much of the content pane is left showing under the tab strip.
 *
 * It does two jobs. The strip used to be anchored to the very bottom of the screen, where a
 * TV's overscan ate the selection underline and part of the labels — this is the safe-area
 * inset the rest of the app already keeps. And because what fills the gap is the top of the
 * pane rather than more background, it is the one thing on screen saying that Down from the
 * strip reveals something.
 */
private val DetailFoldPeek = 34.dp

/**
 * The height one tab's pane gets, from the viewport it has to fit inside.
 *
 * It was a hard 250dp, and `technicalSpecs()` — Video, Codec, Audio, Subtitles, Studio —
 * fell off the bottom of a pane that deliberately cannot scroll. The budget is what is left
 * of the screen once the pane has been scrolled to its resting position under the strip.
 */
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
    (viewportHeight - 132.dp).coerceIn(250.dp, 420.dp)

/**
 * Hands focus to the first target that is actually on screen, and says whether any took it.
 *
 * `focusProperties { down = … }` names exactly one node and throws when that node is not
 * attached — which on these pages is an ordinary state rather than an error. An episode
 * page has no season chips until the seasons arrive, a season scrolled out of its LazyRow
 * is not composed, a tab can be selected before its pane holds anything focusable. Down
 * has to do the obvious thing in every one of those cases, so the direction keys name a
 * *list* of places to try and fall through to the next rather than going dead under
 * somebody's thumb.
 */
internal fun focusFirstAvailable(vararg targets: FocusRequester?): Boolean {
    targets.forEach { target ->
        if (target != null && runCatching { target.requestFocus() }.isSuccess) return true
    }
    return false
}

/**
 * Vertical navigation stated as intent rather than as a destination.
 *
 * Deliberately `onKeyEvent` and not the preview: a press is offered to whatever holds
 * focus first, so a control that means something of its own by Up or Down keeps it, and
 * only an otherwise unhandled press is routed. Returning false leaves Compose's own focus
 * search to try, which is the right last resort. Left and Right are never touched.
 */
internal fun Modifier.onVerticalNavigation(
    up: (() -> Boolean)? = null,
    down: (() -> Boolean)? = null,
): Modifier = onKeyEvent { event ->
    if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
    when (event.key) {
        Key.DirectionUp -> up?.invoke() ?: false
        Key.DirectionDown -> down?.invoke() ?: false
        else -> false
    }
}

internal data class DetailHeroAction(
    val icon: ImageVector,
    val description: String,
    val active: Boolean = false,
    val label: String? = null,
    val onClick: () -> Unit,
)

/** Full-bleed artwork with a protected reading area on the left and at the fold. */
@Composable
internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
    val repository = ServiceLocator.repository
    val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
        repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280)
    }
    Box(modifier.background(DetailBackground)) {
        if (artwork != null) {
            AsyncImage(
                model = artwork,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                alignment = Alignment.TopCenter,
                modifier = Modifier.fillMaxSize(),
            )
        }
        Box(
            Modifier.fillMaxSize().background(
                Brush.horizontalGradient(
                    0f to DetailBackground.copy(alpha = 0.95f),
                    0.42f to DetailBackground.copy(alpha = 0.80f),
                    0.72f to DetailBackground.copy(alpha = 0.22f),
                    1f to DetailBackground.copy(alpha = 0.06f),
                ),
            ),
        )
        Box(
            Modifier.fillMaxSize().background(
                Brush.verticalGradient(
                    0f to Color(0x18000000),
                    0.48f to DetailBackground.copy(alpha = 0.15f),
                    0.78f to DetailBackground.copy(alpha = 0.90f),
                    1f to DetailBackground,
                ),
            ),
        )
    }
}

/** Shared movie/series frame. The list owns vertical motion so focused bands stay visible. */
@Composable
internal fun DetailPageScaffold(
    item: BaseItem,
    facts: List<String>,
    badges: List<String>,
    playLabel: String,
    onPlay: () -> Unit,
    playFocusRequester: FocusRequester,
    tabFocusRequester: FocusRequester,
    contentFocusRequester: FocusRequester,
    modifier: Modifier = Modifier,
    tabs: List<DetailTab> = emptyList(),
    selectedTab: DetailTab = DetailTab.OVERVIEW,
    onSelectTab: (DetailTab) -> Unit = {},
    /**
     * Replaces the tab strip in the band under the hero. An episode page has one thing to
     * navigate — the seasons — so it supplies its own scroller rather than pretending to be
     * a set of tabs; the frame, the fold and the focus contract stay identical either way.
     * It is handed the requester to hand focus back to above it, the one to send focus to
     * below it, and the callback that pins the page to the strip.
     */
    strip: (@Composable (
        heroFocusRequester: FocusRequester,
        contentFocusRequester: FocusRequester,
        onFocused: () -> Unit,
    ) -> Unit)? = null,
    eyebrow: String? = null,
    subtitle: String? = null,
    /**
     * The heading when the item has no logo to show. An episode's page is *about* the
     * episode but *headed* by the show, so it passes the series name here — without it the
     * fallback prints the episode's title twice, once as the heading and once under the
     * number.
     */
    title: String? = null,
    progress: Float = 0f,
    progressLabel: String? = null,
    /**
     * "Estimated finish: 18 August" — how the viewer's own pace projects onto what is left
     * of a series. It sits with the progress information rather than beside it, and is
     * deliberately quiet: it is a nicety on a page whose job is Play.
     *
     * Its own line rather than an addition to [progressLabel], because the progress bar
     * appears only for a part-watched episode and the estimate is at its most useful for
     * somebody who finished one last night and has not started the next.
     */
    paceLabel: String? = null,
    reasons: List<String> = emptyList(),
    /**
     * Set only when the page was opened from the "Shows airing" row, and it takes the
     * accent line the recommendation reason would otherwise have: someone who arrived by
     * pressing "Thursday, 9pm" came for the schedule, not for why the engine likes the
     * show. Never focusable, like the reason it replaces.
     */
    airingNotice: AiringNotice? = null,
    ratings: List<MediaRating> = emptyList(),
    showRatingsStrip: Boolean = true,
    heroActions: List<DetailHeroAction> = emptyList(),
    confirmation: String? = null,
    pageListState: LazyListState = remember(item.id) { LazyListState() },
    onZoneFocused: (DetailZone) -> Unit = {},
    content: @Composable BoxScope.(DetailTab) -> Unit,
) {
    val scope = rememberCoroutineScope()
    // Keep the same requesters when an async trailer action appears. Replacing the list
    // while the viewer is already on Favourites would detach the focused node.
    val allActionRequesters = remember(item.id) {
        List(6) { FocusRequester() }
    }
    val actionRequesters = allActionRequesters.take(heroActions.size)
    var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
    var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
    val heroReturn = if (lastHeroIndex in actionRequesters.indices) {
        actionRequesters[lastHeroIndex]
    } else {
        playFocusRequester
    }
    // The band and the pane as a whole, so a press can reach them when the one node they
    // would rather land on — the selected tab, the season being watched, the first
    // episode card — is not composed on this frame.
    val stripEntryRequester = remember(item.id) { FocusRequester() }
    val contentEntryRequester = remember(item.id) { FocusRequester() }
    // Down out of the hero: the strip, and failing that the content under it. A band with
    // nothing in it yet is a thing to pass through, not a thing to stop at.
    val enterStripFromHero = {
        focusFirstAvailable(tabFocusRequester, stripEntryRequester, contentFocusRequester, contentEntryRequester)
    }
    val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) }
    fun reveal(index: Int, offset: Int = 0) {
        scope.launch { pageListState.animateScrollToItem(index, offset) }
    }
    fun revealHero() {
        scope.launch {
            // LazyColumn also scrolls a newly focused descendant into view. That request
            // can land after onFocusChanged and used to win over reveal(0), leaving Play
            // focused with the logo and metadata above the viewport. Snap once now and
            // once after focus relocation has completed so hero focus always means the
            // complete opening frame.
            pageListState.scrollToItem(0)
            withFrameNanos { }
            pageListState.scrollToItem(0)
        }
    }
    // Focus relocation belongs to LazyColumn and may run after the focus callback. Keep
    // the opening frame pinned for as long as focus remains in the hero, regardless of
    // which relocation wins a particular frame. Moving to Tabs or Content releases it.
    androidx.compose.runtime.LaunchedEffect(pageListState, focusedZone) {
        if (detailHeroScrollTarget(focusedZone) == null) return@LaunchedEffect
        snapshotFlow {
            pageListState.firstVisibleItemIndex to pageListState.firstVisibleItemScrollOffset
        }.collect { (index, offset) ->
            if (index != 0 || offset != 0) pageListState.scrollToItem(0)
        }
    }

    BoxWithConstraints(modifier.fillMaxSize().background(DetailBackground)) {
        // The opening composition is one deliberate TV frame: hero above, tabs anchored
        // to its bottom edge. Content begins below the fold and only enters when the
        // viewer presses Down from the tabs.
        val heroHeight = (maxHeight - DetailStripHeight - DetailFoldPeek).coerceAtLeast(340.dp)
        val paneHeight = detailPaneHeight(maxHeight)
        LazyColumn(
            state = pageListState,
            modifier = Modifier.fillMaxSize(),
        ) {
            item(key = "hero") {
                DetailHero(
                    item = item,
                    facts = facts,
                    badges = badges,
                    eyebrow = eyebrow,
                    subtitle = subtitle,
                    title = title ?: item.name,
                    playLabel = playLabel,
                    onPlay = onPlay,
                    playFocusRequester = playFocusRequester,
                    onNavigateDown = enterStripFromHero,
                    progress = progress,
                    progressLabel = progressLabel,
                    paceLabel = paceLabel,
                    reasons = reasons,
                    airingNotice = airingNotice,
                    ratings = ratings,
                    showRatingsStrip = showRatingsStrip,
                    actions = heroActions,
                    actionRequesters = actionRequesters,
                    height = heroHeight,
                    onActionFocused = { index ->
                        lastHeroIndex = index
                        focusedZone = DetailZone.PLAY
                        onZoneFocused(DetailZone.PLAY)
                        revealHero()
                    },
                    onPlayFocused = {
                        lastHeroIndex = -1
                        focusedZone = DetailZone.PLAY
                        onZoneFocused(DetailZone.PLAY)
                        revealHero()
                    },
                )
            }
            item(key = "tabs") {
                val onStripFocused = {
                    focusedZone = DetailZone.TABS
                    onZoneFocused(DetailZone.TABS)
                    reveal(1, -18)
                }
                // The band as one focus group, so a press that cannot reach the exact
                // stop it wanted still arrives somewhere in the strip.
                Box(
                    Modifier
                        .focusRequester(stripEntryRequester)
                        .focusGroup()
                        .onVerticalNavigation(
                            up = { focusFirstAvailable(heroReturn, playFocusRequester) },
                            down = enterContent,
                        ),
                ) {
                    if (strip != null) {
                        strip(heroReturn, contentFocusRequester, onStripFocused)
                    } else {
                        DetailTabStrip(
                            tabs = tabs,
                            selected = selectedTab,
                            onSelect = onSelectTab,
                            selectedFocusRequester = tabFocusRequester,
                            onExitUp = { focusFirstAvailable(heroReturn, playFocusRequester) },
                            onExitDown = enterContent,
                            onFocused = onStripFocused,
                        )
                    }
                }
            }
            item(key = "content") {
                AnimatedContent(
                    targetState = selectedTab,
                    transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
                    label = "detail-tab-content",
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(start = DetailSideGutter, end = DetailSideGutter, top = 16.dp, bottom = 64.dp)
                        .height(paneHeight)
                        .onFocusChanged {
                            if (it.hasFocus) {
                                focusedZone = DetailZone.CONTENT
                                onZoneFocused(DetailZone.CONTENT)
                                reveal(2, -72)
                            }
                        }
                        .focusRequester(contentEntryRequester)
                        .focusGroup()
                        // Deliberately a focus property and not a key handler: panes
                        // navigate vertically inside themselves (an episode list, its
                        // season chips) and override this where they do. A blanket
                        // handler here would take Up off every card in that list.
                        .focusProperties { up = tabFocusRequester },
                ) { visibleTab ->
                    Box(Modifier.fillMaxSize()) { content(visibleTab) }
                }
            }
        }

        confirmation?.let {
            Text(
                text = it,
                color = Color.White,
                fontSize = 14.sp,
                fontWeight = FontWeight.SemiBold,
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .padding(bottom = 24.dp)
                    .shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
                    .clip(RoundedCornerShape(MembyPanelCorner))
                    .background(MembySurfaceRaised.copy(alpha = 0.93f))
                    .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner))
                    .padding(horizontal = 20.dp, vertical = 10.dp),
            )
        }
    }
}

/** A focused hero is always the complete opening frame, never a scrolled Play-only crop. */
internal fun detailHeroScrollTarget(zone: DetailZone): Int? =
    if (zone == DetailZone.PLAY) 0 else null

@Composable
private fun DetailHero(
    item: BaseItem,
    facts: List<String>,
    badges: List<String>,
    eyebrow: String?,
    subtitle: String?,
    title: String,
    playLabel: String,
    onPlay: () -> Unit,
    playFocusRequester: FocusRequester,
    onNavigateDown: () -> Boolean,
    progress: Float,
    progressLabel: String?,
    paceLabel: String?,
    reasons: List<String>,
    airingNotice: AiringNotice?,
    ratings: List<MediaRating>,
    showRatingsStrip: Boolean,
    actions: List<DetailHeroAction>,
    actionRequesters: List<FocusRequester>,
    height: Dp,
    onPlayFocused: () -> Unit,
    onActionFocused: (Int) -> Unit,
) {
    // Settings promises the logo preference applies to titles generally; only the
    // screensaver honoured it. The dark-logo fallback comes with it — a black title
    // treatment on this near-black scrim is an invisible heading.
    val repository = ServiceLocator.repository
    val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
        if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
    }
    val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
    // Down belongs to the hero as a whole, not to the row of buttons inside it: whatever
    // in here holds focus, the press means "take me to the band under this".
    Box(Modifier.fillMaxWidth().height(height).onVerticalNavigation(down = onNavigateDown)) {
        DetailBackdrop(item, Modifier.fillMaxSize())
        Column(
            modifier = Modifier
                .align(Alignment.BottomStart)
                .padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp)
                .fillMaxWidth(0.58f),
        ) {
            if (logo != null) {
                AsyncImage(
                    model = logo,
                    contentDescription = item.name,
                    contentScale = ContentScale.Fit,
                    alignment = Alignment.CenterStart,
                    modifier = Modifier.width(330.dp).height(92.dp),
                )
            } else {
                Text(
                    text = title,
                    color = Color.White,
                    fontSize = 38.sp,
                    lineHeight = 42.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            // An episode's logo belongs to its *series*, so without this the page would
            // name the show and never say which episode it is about. The number goes
            // first and the episode's own title second: the viewer already knows the
            // show they picked, and "Season 3 · Episode 4" is what they came to confirm.
            if (eyebrow != null) {
                Spacer(Modifier.height(10.dp))
                Text(
                    text = eyebrow,
                    color = DetailAccent,
                    fontSize = 13.sp,
                    fontWeight = FontWeight.Bold,
                    letterSpacing = 1.6.sp,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            if (subtitle != null) {
                Spacer(Modifier.height(6.dp))
                Text(
                    text = subtitle,
                    color = Color.White,
                    fontSize = 26.sp,
                    lineHeight = 30.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            Spacer(Modifier.height(10.dp))
            DetailFactRow(facts = facts, badges = badges)
            if (showRatingsStrip) Spacer(Modifier.height(8.dp))
            RatingsStrip(ratings, visible = showRatingsStrip, reserveSpace = true)
            if (item.genres.isNotEmpty()) {
                Spacer(Modifier.height(8.dp))
                Text(
                    text = item.genres.take(4).joinToString(ValueSeparator),
                    color = DetailMutedText,
                    fontSize = 14.sp,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            Spacer(Modifier.height(10.dp))
            Text(
                text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
                color = DetailText,
                fontSize = 15.sp,
                lineHeight = 20.sp,
                maxLines = 3,
                overflow = TextOverflow.Ellipsis,
            )
            if (progress > 0f) {
                Spacer(Modifier.height(12.dp))
                DetailProgress(progress, progressLabel)
            }
            paceLabel?.let {
                // Tighter under the bar than under the synopsis: with a bar above it this
                // is the second half of one thought, without one it is a line of its own.
                Spacer(Modifier.height(if (progress > 0f) 6.dp else 12.dp))
                Text(
                    text = it,
                    color = DetailQuietText,
                    fontSize = 12.sp,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            if (airingNotice != null) {
                Spacer(Modifier.height(10.dp))
                AiringNoticeBand(airingNotice)
            } else if (reasons.isNotEmpty()) {
                Spacer(Modifier.height(10.dp))
                Text(
                    text = reasons.first(),
                    color = DetailAccent,
                    fontSize = 13.sp,
                    fontWeight = FontWeight.SemiBold,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            Spacer(Modifier.height(16.dp))
            Row(
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier.focusGroup(),
            ) {
                MembyPlayButton(
                    label = playLabel,
                    onClick = onPlay,
                    onFocused = onPlayFocused,
                    modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester),
                )
                actions.forEachIndexed { index, action ->
                    DetailCircularAction(
                        action = action,
                        onFocused = { onActionFocused(index) },
                        modifier = Modifier.focusRequester(actionRequesters[index]),
                    )
                }
            }
        }
    }
}

/**
 * The schedule the viewer pressed, restated on the page it opened.
 *
 * A tinted band rather than one more accent line: it has to be findable in the half-second
 * after the page appears, by someone who chose this show *because* of when it airs. It sits
 * where the recommendation reason does, immediately above the actions, so nothing below the
 * hero moves.
 */
@Composable
private fun AiringNoticeBand(notice: AiringNotice) {
    val shape = RoundedCornerShape(MembyCardCorner)
    Column(
        modifier = Modifier
            .clip(shape)
            .background(DetailAccent.copy(alpha = 0.13f))
            .border(1.dp, DetailAccent.copy(alpha = 0.45f), shape)
            .padding(horizontal = 14.dp, vertical = 9.dp),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text(
                text = notice.label,
                color = DetailAccent,
                fontSize = 11.sp,
                fontWeight = FontWeight.Bold,
                letterSpacing = 1.4.sp,
                maxLines = 1,
            )
            if (notice.headline.isNotEmpty()) {
                Text(
                    text = notice.headline,
                    color = Color.White,
                    fontSize = 14.sp,
                    fontWeight = FontWeight.SemiBold,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.padding(start = 12.dp),
                )
            }
        }
        if (notice.detail.isNotEmpty()) {
            Spacer(Modifier.height(3.dp))
            Text(
                text = notice.detail,
                color = DetailText,
                fontSize = 13.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

/** Compatibility entry point for previews/tests; all rendering lives in RatingsStrip. */
@Composable
internal fun DetailMovieRatings(
    ratings: List<MediaRating>,
    modifier: Modifier = Modifier,
) {
    RatingsStrip(ratings, visible = true, modifier = modifier)
}

@Composable
private fun DetailCircularAction(
    action: DetailHeroAction,
    onFocused: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var focused by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(if (focused) 1.1f else 1f, tween(100), label = "hero-action-focus")
    Row(
        modifier = modifier
            .then(if (action.label == null) Modifier.size(48.dp) else Modifier.height(48.dp))
            .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
            .shadow(if (focused) 16.dp else 5.dp, CircleShape)
            .clip(CircleShape)
            .background(if (action.active) DetailAccent else MembySurfaceRaised.copy(alpha = 0.70f))
            .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape)
            .semantics { contentDescription = action.description }
            .onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
            .clickable(onClick = action.onClick)
            .padding(horizontal = if (action.label == null) 0.dp else 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center,
    ) {
        Icon(action.icon, action.description, tint = Color.White, modifier = Modifier.size(22.dp))
        action.label?.let { label ->
            Spacer(Modifier.width(8.dp))
            Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1)
        }
    }
}

@Composable
internal fun DetailFactRow(
    facts: List<String>,
    badges: List<String> = emptyList(),
    modifier: Modifier = Modifier,
) {
    Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
        facts.forEachIndexed { index, value ->
            if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
            Text(value, color = DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
        }
        // Four, not three: a 4K/HDR/HEVC movie used up the whole allowance and dropped
        // the airing badge appended after them, which is the one that is news.
        badges.take(4).forEach { badge ->
            Spacer(Modifier.width(8.dp))
            MediaBadge(badge)
        }
    }
}

@Composable
private fun DetailTabStrip(
    tabs: List<DetailTab>,
    selected: DetailTab,
    onSelect: (DetailTab) -> Unit,
    selectedFocusRequester: FocusRequester,
    onExitUp: () -> Boolean,
    onExitDown: () -> Boolean,
    onFocused: () -> Unit,
) {
    Box(
        Modifier
            .fillMaxWidth()
            .height(DetailStripHeight)
            .background(DetailBackground)
            .padding(horizontal = DetailSideGutter),
    ) {
        Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
        Row(
            modifier = Modifier
                .fillMaxSize()
                .focusGroup()
                .onVerticalNavigation(up = onExitUp, down = onExitDown),
            horizontalArrangement = Arrangement.spacedBy(34.dp),
            verticalAlignment = Alignment.Bottom,
        ) {
            // Whatever else happens, one tab carries the requester the rest of the page
            // aims Up and Down at. A selected tab that is not in the list — a key
            // remembered from the other kind of item, for the one frame before it is
            // resolved — must not leave the band with nothing attached to land on.
            val anchor = tabs.firstOrNull { it == selected } ?: tabs.firstOrNull()
            tabs.forEach { tab ->
                var focused by remember(tab) { mutableStateOf(false) }
                Column(
                    modifier = Modifier
                        .width(IntrinsicSize.Max)
                        .then(if (tab == anchor) Modifier.focusRequester(selectedFocusRequester) else Modifier)
                        .testTag("detail-tab-${tab.key}")
                        .onFocusChanged {
                            focused = it.isFocused
                            if (it.isFocused) { onSelect(tab); onFocused() }
                        }
                        .clickable { onSelect(tab) },
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    Text(
                        tab.label,
                        color = if (selected == tab || focused) Color.White else DetailQuietText,
                        fontSize = 15.sp,
                        fontWeight = if (selected == tab || focused) FontWeight.Bold else FontWeight.Medium,
                        maxLines = 1,
                        modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 13.dp),
                    )
                    Box(
                        Modifier.fillMaxWidth().height(3.dp).background(
                            if (selected == tab || focused) DetailAccent else Color.Transparent,
                        ),
                    )
                }
            }
            Spacer(Modifier.weight(1f))
            // The tab's content begins below the fold by design. Nothing said so; the
            // peek under this strip and this chevron are what say it. Never focusable —
            // it is a caption on the Down key, not another thing to land on.
            Icon(
                Icons.Default.KeyboardArrowDown,
                contentDescription = null,
                tint = DetailQuietText,
                modifier = Modifier.size(18.dp).padding(bottom = 2.dp),
            )
        }
    }
}

@Composable
internal fun DetailProgress(progress: Float, label: String?, modifier: Modifier = Modifier) {
    Row(modifier, verticalAlignment = Alignment.CenterVertically) {
        Box(Modifier.width(250.dp).height(5.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.22f))) {
            Box(Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(5.dp).background(DetailAccent))
        }
        label?.let { Text(it, color = DetailMutedText, fontSize = 12.sp, modifier = Modifier.padding(start = 12.dp)) }
    }
}

@Composable
internal fun DetailFocusablePane(
    focusRequester: FocusRequester,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    var focused by remember { mutableStateOf(false) }
    Box(
        modifier
            .fillMaxSize()
            .focusRequester(focusRequester)
            .onFocusChanged { focused = it.isFocused }
            .focusable()
            .clip(RoundedCornerShape(MembyCardCorner))
            .border(if (focused) 2.dp else 1.dp, if (focused) Color.White.copy(alpha = 0.8f) else Color.Transparent, RoundedCornerShape(MembyCardCorner))
            .background(if (focused) Color.White.copy(alpha = 0.035f) else Color.Transparent)
            .padding(16.dp),
    ) { content() }
}

@Composable
internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) {
    Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) {
        rows.forEach { row ->
            Row(Modifier.fillMaxWidth()) {
                Text(row.label, color = DetailQuietText, fontSize = 13.sp, fontWeight = FontWeight.Medium, modifier = Modifier.width(labelWidth))
                Text(row.value, color = DetailText, fontSize = 14.sp, lineHeight = 19.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
            }
        }
    }
}

@Composable
internal fun DetailOverviewPane(
    item: BaseItem,
    credits: List<TechnicalSpec>,
    focusRequester: FocusRequester,
    modifier: Modifier = Modifier,
    supportingText: String? = null,
) {
    DetailFocusablePane(focusRequester, modifier) {
        Column {
            Text(item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = DetailText, fontSize = 16.sp, lineHeight = 23.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
            supportingText?.let { Spacer(Modifier.height(10.dp)); Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) }
            Spacer(Modifier.height(16.dp))
            DetailMetaRows(credits.take(4))
        }
    }
}

@Composable
internal fun DetailCastAndDetailsPane(
    item: BaseItem,
    credits: List<TechnicalSpec>,
    specs: List<TechnicalSpec>,
    detailsLoaded: Boolean,
    focusRequester: FocusRequester,
    modifier: Modifier = Modifier,
) {
    val releaseAndTechnical = remember(item.id, specs, credits) {
        val alreadyCredited = credits.map(TechnicalSpec::label).toSet()
        buildList {
            item.productionYear?.let { add(TechnicalSpec("Released", it.toString())) }
            item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
            item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
            // Studio is in both vocabularies. It only became visible as a duplicate once
            // the pane stopped clipping its own second half.
            addAll(specs.filterNot { it.label in alreadyCredited })
        }
    }
    DetailFocusablePane(focusRequester, modifier) {
        if (!detailsLoaded && item.people.isEmpty() && specs.isEmpty()) {
            Text("Loading cast and details…", color = DetailQuietText, fontSize = 15.sp)
        } else {
            Column {
                if (item.cast.isNotEmpty()) CastRail(people = item.cast, compact = true, showTitle = true)
                Spacer(Modifier.height(12.dp))
                Row(horizontalArrangement = Arrangement.spacedBy(42.dp)) {
                    DetailMetaRows(credits, Modifier.weight(1f))
                    DetailMetaRows(releaseAndTechnical, Modifier.weight(1f))
                }
            }
        }
    }
}

@Composable
internal fun DetailMoreLikeThisPane(
    items: List<BaseItem>,
    loading: Boolean,
    onSelect: (BaseItem) -> Unit,
    firstFocusRequester: FocusRequester,
    listState: LazyListState,
    modifier: Modifier = Modifier,
) {
    when {
        loading -> DetailFocusablePane(firstFocusRequester, modifier) { Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp) }
        items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) { Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp) }
        else -> LazyRow(
            state = listState,
            modifier = modifier.fillMaxSize().focusGroup(),
            horizontalArrangement = Arrangement.spacedBy(18.dp),
            contentPadding = PaddingValues(horizontal = 7.dp, vertical = 7.dp),
        ) {
            itemsIndexed(items, key = { _, it -> it.id }) { index, related ->
                DetailPosterCard(
                    item = related,
                    onClick = { onSelect(related) },
                    modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
                )
            }
        }
    }
}

@Composable
private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
    val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) }
    var focused by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus")
    Column(
        modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick),
    ) {
        Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(MembySurfaceRaised).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
            if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
        }
        Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp))
    }
}

@Composable
internal fun DetailPanePlaceholder(message: String, modifier: Modifier = Modifier) {
    Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text(message, color = DetailQuietText, fontSize = 15.sp) }
}
