package com.ponzischeme89.memby.ui.screensaver

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.useTextTitleForLogo
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
import com.ponzischeme89.memby.ui.settings.SettingsSheet
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date

// Background-prefetch tuning for the rotating queue.
private const val PREFETCH_AHEAD = 6   // start fetching this many items before the end
private const val MAX_QUEUE = 600      // cap the in-memory queue…
private const val TRIM_TO = 400        // …trimming already-shown items down to this

/** Soft drop shadow that keeps foreground text legible over bright/white artwork. */
private val TextShadow = Shadow(
    color = Color(0xCC000000),
    offset = Offset(0f, 3f),
    blurRadius = 10f,
)

/**
 * A small holder that lets a host (e.g. the DreamService) drive playback from a
 * key it intercepts outside Compose focus (the remote's Play/Pause media key).
 * The composable registers [playCurrent] for the currently shown item.
 */
class ScreensaverActions {
    @Volatile
    var playCurrent: (() -> Unit)? = null
}

/**
 * The shared backdrop slideshow, used by both the in-app preview activity and the
 * system Daydream. Remote model:
 *  - ◄ / ►      previous / next item (when the panel is closed)
 *  - ▲ / ▼      show / hide the details + actions panel
 *  - OK/Center  open the panel, or perform the focused action when it's open
 *  - Play/Pause start playback of the current item
 *  - Back       close the panel, or exit if it's already closed
 */
@Composable
fun ScreensaverContent(
    onPlay: (url: String, title: String) -> Unit,
    onExit: () -> Unit,
    actions: ScreensaverActions? = null,
    startupMessage: String? = null,
) {
    val repo = ServiceLocator.repository
    // Nullable initial so we don't flash "not configured" before settings load.
    val settings by repo.settingsFlow.collectAsState(initial = null)

    when {
        settings == null -> MessageScreen(text = "Memby starting up….", onExit = onExit)
        settings?.isSignedIn != true -> MessageScreen(
            text = "Open “Memby” on this device to configure your server and sign in.",
            onExit = onExit,
        )
        else -> Slideshow(
            onPlay = onPlay,
            onExit = onExit,
            actions = actions,
            showTitleLogo = settings?.showTitleLogo ?: true,
            ringColor = ringColorFromHex(settings?.ringColorHex),
            embyServerId = settings?.serverId,
            warmBackdropUrl = settings?.lastBackdropUrl,
            hideWatchedMovies = settings?.hideWatchedMovies == true,
            startupMessage = startupMessage,
        )
    }
}

@Composable
private fun Slideshow(
    onPlay: (url: String, title: String) -> Unit,
    onExit: () -> Unit,
    actions: ScreensaverActions?,
    showTitleLogo: Boolean,
    ringColor: Color,
    embyServerId: String?,
    warmBackdropUrl: String?,
    hideWatchedMovies: Boolean,
    startupMessage: String?,
) {
    val repo = ServiceLocator.repository
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    var items by remember { mutableStateOf<List<BaseItem>>(emptyList()) }
    var index by remember { mutableIntStateOf(0) }
    var panelOpen by remember { mutableStateOf(false) }
    var settingsOpen by remember { mutableStateOf(false) }
    var loading by remember { mutableStateOf(true) }
    var loadingMore by remember { mutableStateOf(false) }
    var loadError by remember { mutableStateOf<String?>(null) }
    var toast by remember(startupMessage) { mutableStateOf(startupMessage) }
    var reloadKey by remember { mutableIntStateOf(0) }
    val favoriteOverrides = remember { mutableStateMapOf<String, Boolean>() }
    val favoriteMutations = remember { mutableStateMapOf<String, Int>() }

    val rootFocus = remember { FocusRequester() }
    val playFocus = remember { FocusRequester() }
    val logoRotation = remember { Animatable(0f) }
    val introBrandAlpha = remember { Animatable(0f) }
    val slideRevealShade = remember { Animatable(0f) }

    // Normalised slide progress in [0f,1f]. A single coroutine (the slide timer below)
    // drives both this ring and the slide advance, so they share one clock and can't drift.
    val slideProgress = remember { mutableFloatStateOf(0f) }
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(reloadKey, hideWatchedMovies) {
        loading = true
        loadError = null
        runCatching { repo.getScreensaverItems() }
            .onSuccess { fetched ->
                val queue = visibleWithWatchedPreference(fetched, hideWatchedMovies).shuffled()
                // The lightweight startup request may already be on-screen. Keep that
                // exact item at the front rather than swapping through several results
                // as competing requests finish; the rest of the random queue follows it.
                val visible = items.getOrNull(index)
                if (visible == null) {
                    items = queue
                    index = 0
                } else {
                    items = listOf(visible) + queue.filter { it.id != visible.id }
                    index = 0
                }
                loading = false
            }
            .onFailure { loadError = friendlyEmbyError(it); loading = false }
    }

    // Do not wait for the full 200-item queue before showing a first real backdrop.
    // This runs alongside it and normally wins on a cold launch.
    LaunchedEffect(reloadKey, hideWatchedMovies) {
        runCatching { repo.getStartupBackdropMovie() }
            .onSuccess { movie ->
                if (
                    movie != null &&
                    visibleWithWatchedPreference(listOf(movie), hideWatchedMovies).isNotEmpty() &&
                    items.isEmpty()
                ) {
                    items = listOf(movie)
                    loading = false
                }
            }
    }

    // Fetches another random batch in the background as the user nears the end of
    // the current queue and appends the new (deduped) items — so the screensaver
    // keeps surfacing fresh titles instead of looping the first batch forever. The
    // queue is trimmed from the front to stay bounded over long uptimes.
    fun maybePrefetch() {
        val size = items.size
        if (loadingMore || size == 0 || index < size - PREFETCH_AHEAD) return
        loadingMore = true
        toast = "Finding more from your library…"
        scope.launch {
            val more = visibleWithWatchedPreference(
                runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()),
                hideWatchedMovies,
            )
            if (more.isNotEmpty()) {
                val existing = items.mapTo(HashSet()) { it.id }
                val fresh = more.filter { it.id !in existing }
                // If the library has nothing new, re-use the reshuffled batch so it
                // still keeps moving (just in a different random order).
                val refill = fresh.ifEmpty { more }.shuffled()
                var merged = items + refill
                if (merged.size > MAX_QUEUE) {
                    val drop = merged.size - TRIM_TO
                    merged = merged.drop(drop)
                    index = (index - drop).coerceAtLeast(0)
                }
                items = merged
                toast = if (fresh.isNotEmpty()) {
                    "Queued ${fresh.size} more titles"
                } else {
                    "Refreshed the queue with a new order"
                }
            } else {
                toast = "No additional titles found"
            }
            loadingMore = false
        }
    }

    fun advance() {
        val size = items.size
        if (size == 0) return
        // Loop back only as a fallback if a refill hasn't landed yet.
        index = if (index + 1 < size) index + 1 else 0
        maybePrefetch()
    }

    // The one authoritative slide timer is defined below — after `hasContent` and the
    // navigation helpers it depends on — so there is a single source of truth.

    // Return focus to the root when the panel closes so key events keep flowing.
    // Guarded: the Play button lives inside AnimatedVisibility and may attach a
    // frame late; requestFocus() on an unattached requester would otherwise throw.
    LaunchedEffect(panelOpen, settingsOpen, items.isEmpty()) {
        if (settingsOpen) {
            // SettingsSheet owns focus while it is on screen.
        } else if (panelOpen) {
            delay(50)
            runCatching { playFocus.requestFocus() }
        } else {
            runCatching { rootFocus.requestFocus() }
        }
    }

    // Auto-dismiss transient toasts.
    LaunchedEffect(toast) {
        if (toast != null) { delay(3500); toast = null }
    }

    val current = items.getOrNull(index)
    val currentUpdated by rememberUpdatedState(current)
    val isFav = current?.let { favoriteOverrides[it.id] ?: it.isFavorite } ?: false
    val hasContent = items.isNotEmpty()
    // Keep a real Emby backdrop available for the next cold start. Coil's disk cache
    // makes this appear instantly in the usual case, before the fresh queue arrives.
    LaunchedEffect(current?.id) {
        current?.let { item ->
            repo.backdropUrl(item)?.let { ServiceLocator.settings.setLastBackdropUrl(it) }
        }
    }

    // A brief full turn gives the persistent brand mark a small, purposeful cue every
    // time the current title changes — including manual previous/next navigation.
    LaunchedEffect(index) {
        logoRotation.snapTo(0f)
        logoRotation.animateTo(360f, animationSpec = tween(durationMillis = 1_100))
    }
    LaunchedEffect(index) {
        slideRevealShade.snapTo(0.38f)
        slideRevealShade.animateTo(0f, animationSpec = tween(durationMillis = 1_100))
    }
    // A small first-launch signature: the app name appears beside the Emby mark and
    // quietly disappears, leaving the artwork to take over.
    LaunchedEffect(Unit) {
        introBrandAlpha.snapTo(0f)
        introBrandAlpha.animateTo(0.94f, animationSpec = tween(durationMillis = 500))
        delay(1_900)
        introBrandAlpha.animateTo(0f, animationSpec = tween(durationMillis = 900))
    }

    fun next() { if (items.isNotEmpty()) { index = (index + 1) % items.size; maybePrefetch() } }
    fun prev() { if (items.isNotEmpty()) index = (index - 1 + items.size) % items.size }

    // Single authoritative slide timer. One lifecycle-aware coroutine animates the
    // progress ring 0f→1f over the slide duration and then advances, so the ring and
    // the slide change share one clock and cannot drift. It restarts (snapping progress
    // back to 0) whenever the slide changes (auto or manual), the queue reloads, or the
    // panel opens/closes. repeatOnLifecycle pauses it while the Activity/DreamService is
    // below RESUMED and resets it on resume; leaving the composition cancels it, so no
    // duplicate timers survive a lifecycle change. Not keyed on the items list, so
    // background refills don't restart it.
    LaunchedEffect(index, panelOpen, settingsOpen, reloadKey, hasContent) {
        if (panelOpen || settingsOpen || !hasContent) return@LaunchedEffect
        lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
            val durationMillis = repo.rotationIntervalMillis()
            slideProgress.floatValue = 0f
            // Drive progress from real elapsed frame time rather than a tween, so each
            // slide lasts exactly `durationMillis` and keeps progressing even when the
            // system animator-duration scale is 0. withFrameMillis keeps it smooth.
            val startMillis = withFrameMillis { it }
            var elapsed = 0L
            while (elapsed < durationMillis) {
                elapsed = withFrameMillis { it } - startMillis
                slideProgress.floatValue = (elapsed.toFloat() / durationMillis).coerceIn(0f, 1f)
            }
            advance()
        }
    }

    fun playTrailer(item: BaseItem?) {
        val target = item ?: return
        toast = "Finding trailer…"
        scope.launch {
            runCatching { repo.getLocalTrailer(target.id) }
                .onSuccess { trailer ->
                    if (trailer == null) {
                        toast = "No trailer is available for ${target.name}."
                    } else {
                        runCatching { repo.resolvePlayable(trailer) }
                            .onSuccess { onPlay(it.url, "${target.name} trailer") }
                            .onFailure { toast = friendlyEmbyError(it) }
                    }
                }
                .onFailure { toast = friendlyEmbyError(it) }
        }
    }

    fun setFavorite(desired: Boolean) {
        val item = current ?: return
        val previous = favoriteOverrides[item.id] ?: item.isFavorite
        val mutation = (favoriteMutations[item.id] ?: 0) + 1
        favoriteMutations[item.id] = mutation
        favoriteOverrides[item.id] = desired // optimistic
        toast = if (desired) "Added to favourites" else "Removed from favourites"
        scope.launch {
            runCatching { repo.setFavorite(item.id, desired) }
                .onSuccess { if (favoriteMutations[item.id] == mutation) favoriteOverrides[item.id] = it }
                .onFailure {
                    if (favoriteMutations[item.id] == mutation) {
                        favoriteOverrides[item.id] = previous
                        toast = friendlyEmbyError(it)
                    }
                }
        }
    }

    // Let a host (DreamService) trigger playback from the media key.
    DisposableEffect(actions) {
        actions?.playCurrent = { playTrailer(currentUpdated) }
        onDispose { actions?.playCurrent = null }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
            .focusRequester(rootFocus)
            .focusable()
            .onPreviewKeyEvent { event ->
                if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
                // The slide root normally owns directional navigation. Once Settings is
                // open, leave those keys to the sheet's focused controls; only Back is
                // handled here so it reliably dismisses the overlay.
                if (settingsOpen) {
                    return@onPreviewKeyEvent when (event.key) {
                        Key.Back -> { settingsOpen = false; true }
                        else -> false
                    }
                }
                when (event.key) {
                    Key.Back -> {
                        when {
                            panelOpen -> { panelOpen = false; true }
                            else -> { onExit(); true }
                        }
                    }
                    Key.MediaPlay, Key.MediaPlayPause -> { playTrailer(current); true }
                    else -> if (!hasContent) {
                        // Loading / error state: OK retries, other keys ignored.
                        when (event.key) {
                            Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
                                if (loadError != null) reloadKey++; true
                            }
                            else -> false
                        }
                    } else when (event.key) {
                        Key.DirectionUp, Key.DirectionDown -> { panelOpen = !panelOpen; true }
                        Key.DirectionCenter, Key.Enter, Key.NumPadEnter ->
                            if (!panelOpen) { panelOpen = true; true } else false
                        Key.DirectionRight -> if (!panelOpen) { next(); true } else false
                        Key.DirectionLeft -> if (!panelOpen) { prev(); true } else false
                        else -> false
                    }
                }
            },
        contentAlignment = Alignment.Center,
    ) {
        if (current == null) {
            if (warmBackdropUrl != null) {
                AsyncImage(
                    model = ImageRequest.Builder(context).data(warmBackdropUrl).crossfade(false).build(),
                    contentDescription = null,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier.fillMaxSize(),
                )
            } else {
                // First-ever start: a calm loading surface instead of a blank black frame.
                Box(
                    Modifier.fillMaxSize().background(
                        Brush.linearGradient(
                            listOf(Color(0xFF17232B), Color(0xFF0B0E11), Color(0xFF101D17)),
                        ),
                    ),
                )
            }
        }
        // Backdrop with a cross-fade between items.
        Crossfade(
            targetState = current,
            animationSpec = tween(durationMillis = 1200),
            label = "backdrop",
        ) { item ->
            val url = item?.let { repo.backdropUrl(it) }
            if (url != null) {
                AsyncImage(
                    model = ImageRequest.Builder(context).data(url).crossfade(false).build(),
                    contentDescription = item.name,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier
                        .fillMaxSize()
                        .graphicsLayer {
                            // Keep 60fps zoom work in the draw layer, avoiding full
                            // recomposition of all slide content on every frame.
                            val p = slideProgress.floatValue.coerceIn(0f, 1f)
                            val eased = p * p * (3f - 2f * p)
                            val scale = 1.28f - (0.28f * eased)
                            scaleX = scale
                            scaleY = scale
                        },
                )
            }
        }

        // A short shadow reveal makes the backdrop transition more deliberate without
        // obscuring the title treatment that follows it.
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.Black.copy(alpha = slideRevealShade.value)),
        )

        // L-shaped scrim (bottom + left) so text stays readable even over bright
        // or near-white backdrops. The left gradient anchors the text column; the
        // bottom gradient covers the info/actions area.
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(
                    Brush.verticalGradient(
                        0.30f to Color.Transparent,
                        1f to Color(0xF7000000),
                    )
                )
        )
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(
                    Brush.horizontalGradient(
                        0f to Color(0xC0000000),
                        0.55f to Color.Transparent,
                    )
                )
        )

        // Persistent Emby brand mark in the very top-left, shown on every slide. Purely
        // decorative — takes no focus and makes no accessibility announcement.
        Row(
            modifier = Modifier
                .align(Alignment.TopStart)
                .padding(start = 32.dp, top = 32.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(14.dp),
        ) {
            Image(
                painter = painterResource(R.drawable.emby_logo),
                contentDescription = null,
                modifier = Modifier
                    .size(64.dp)
                    .graphicsLayer { rotationZ = logoRotation.value }
                    .clearAndSetSemantics {},
            )
            Text(
                text = "Memby",
                color = Color.White,
                fontSize = 25.sp,
                fontWeight = FontWeight.SemiBold,
                style = TextStyle(shadow = TextShadow),
                modifier = Modifier.graphicsLayer { alpha = introBrandAlpha.value },
            )
        }

        if (current != null) {
            InfoAndActions(
                item = current,
                isFavorite = isFav,
                panelOpen = panelOpen,
                showTitleLogo = showTitleLogo,
                playFocus = playFocus,
                onPlay = { playTrailer(current) },
                onToggleFavorite = { setFavorite(!isFav) },
                onNext = ::next,
                onPrev = ::prev,
                onOpenSettings = { settingsOpen = true },
                onExit = onExit,
                modifier = Modifier.align(Alignment.BottomStart),
            )
        }

        if (!hasContent) {
            Text(
                text = when {
                    loading -> "Memby starting up…."
                    loadError != null -> "$loadError\n\nPress OK to retry."
                    else -> "No movies or shows with backdrops were found."
                },
                color = Color.White,
                fontSize = 22.sp,
                modifier = Modifier.padding(48.dp),
            )
        }

        toast?.let {
            Box(
                modifier = Modifier
                    .align(Alignment.TopEnd)
                    .padding(32.dp)
                    .background(Color(0xCC000000), RoundedCornerShape(8.dp))
                    .padding(horizontal = 20.dp, vertical = 12.dp),
            ) {
                Text(text = it, color = Color.White, fontSize = 16.sp)
            }
        }

        // Subtle circular slide-progress indicator (bottom-right). Hidden while the
        // panel is open (slideshow paused) or before content loads. Reads progress in
        // the draw phase so it repaints per frame without recomposing the slideshow.
        if (hasContent && !panelOpen) {
            SlideProgressRing(
                progress = { slideProgress.floatValue },
                color = ringColor,
                modifier = Modifier
                    .align(Alignment.BottomEnd)
                    .padding(end = 48.dp, bottom = 43.dp),
            )
        }

        CurrentTime(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(end = 98.dp, bottom = 41.dp),
        )

        if (settingsOpen) {
            SettingsSheet(onClose = { settingsOpen = false })
        }
    }
}

/** A high-legibility, always-current clock beside the slide-progress indicator. */
@Composable
private fun CurrentTime(modifier: Modifier = Modifier) {
    var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
    LaunchedEffect(Unit) {
        while (true) {
            now = System.currentTimeMillis()
            // Tick exactly after the next minute changes, rather than polling each second.
            delay(60_000L - (now % 60_000L) + 30L)
        }
    }
    val formatter = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
    Text(
        text = formatter.format(Date(now)),
        color = Color.White.copy(alpha = 0.92f),
        fontSize = 30.sp,
        fontWeight = FontWeight.SemiBold,
        style = TextStyle(shadow = TextShadow),
        modifier = modifier,
    )
}

/**
 * A small, understated circular slide-progress indicator. Fills clockwise from the
 * 12 o'clock position over the slide duration. Purely decorative: it takes no focus
 * and makes no accessibility announcements.
 *
 * [progress] is read lazily inside the draw phase (a `() -> Float`) so the ring
 * repaints each frame as the value animates without recomposing its caller.
 */
@Composable
private fun SlideProgressRing(
    progress: () -> Float,
    color: Color,
    modifier: Modifier = Modifier,
) {
    Canvas(
        modifier = modifier
            .size(32.dp)
            .clearAndSetSemantics {},
    ) {
        val stroke = 3.dp.toPx()
        val inset = stroke / 2f
        val arcSize = Size(size.width - stroke, size.height - stroke)
        val topLeft = Offset(inset, inset)

        // Faint dark disc so the ring stays legible over bright/near-white artwork.
        drawCircle(color = Color.Black.copy(alpha = 0.30f), radius = size.minDimension / 2f)
        // Thin semi-transparent background ring.
        drawArc(
            color = Color.White.copy(alpha = 0.28f),
            startAngle = 0f,
            sweepAngle = 360f,
            useCenter = false,
            topLeft = topLeft,
            size = arcSize,
            style = Stroke(width = stroke, cap = StrokeCap.Round),
        )
        // Foreground arc (the chosen colour) showing elapsed progress, clockwise from
        // 12 o'clock.
        drawArc(
            color = color.copy(alpha = 0.90f),
            startAngle = -90f,
            sweepAngle = SlideProgressMath.sweepAngle(progress()),
            useCenter = false,
            topLeft = topLeft,
            size = arcSize,
            style = Stroke(width = stroke, cap = StrokeCap.Round),
        )
    }
}

/** Parses an RRGGBB hex string to an opaque [Color], falling back to white. */
internal fun ringColorFromHex(hex: String?): Color =
    runCatching { Color(("FF" + (hex ?: "").removePrefix("#").trim()).toLong(16)) }
        .getOrDefault(Color.White)

/** Pure geometry for [SlideProgressRing], split out so it is unit-testable. */
internal object SlideProgressMath {
    /** Clockwise sweep in degrees for a normalised [progress], clamped to [0f,1f]. */
    fun sweepAngle(progress: Float): Float = progress.coerceIn(0f, 1f) * 360f
}

@Composable
private fun InfoAndActions(
    item: BaseItem,
    isFavorite: Boolean,
    panelOpen: Boolean,
    showTitleLogo: Boolean,
    playFocus: FocusRequester,
    onPlay: () -> Unit,
    onToggleFavorite: () -> Unit,
    onNext: () -> Unit,
    onPrev: () -> Unit,
    onOpenSettings: () -> Unit,
    onExit: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val tagline = item.taglines
        .asSequence()
        .map { it.trim().trim('"') }
        .firstOrNull { it.length > 2 }
    Row(
        modifier = modifier
            .fillMaxWidth()
            // Without a tagline the block is shorter; lift it to retain the same
            // visual balance rather than letting the title fall toward the controls.
            .padding(start = 56.dp, bottom = 30.dp, end = 56.dp),
        horizontalArrangement = Arrangement.spacedBy(28.dp),
        verticalAlignment = Alignment.Bottom,
    ) {
        // Poster (only meaningful once the user opens the panel).
        if (panelOpen) {
            val repo = ServiceLocator.repository
            val posterUrl = repo.primaryUrl(item)
            if (posterUrl != null) {
                AsyncImage(
                    model = posterUrl,
                    contentDescription = null,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier
                        .width(150.dp)
                        .height(225.dp)
                        .clip(RoundedCornerShape(10.dp))
                        .background(Color(0xFF1A2027)),
                )
            }
        }

        Column(
            // Wider on big screens so titles and the synopsis run further across.
            // A little narrower when the panel (and poster) is open to leave room.
            modifier = Modifier.fillMaxWidth(if (panelOpen) 0.74f else 0.82f),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            // Prefer the item's Emby "Logo" artwork over a plain-text title, when the
            // user has it enabled and this item actually has a logo image.
            val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null
            if (!useTextTitleForLogo(logoUrl)) {
                AsyncImage(
                    model = logoUrl,
                    contentDescription = item.name,
                    contentScale = ContentScale.Fit,
                    alignment = Alignment.CenterStart,
                    modifier = Modifier
                        // A fixed logo stage keeps artwork consistently sized even when
                        // Emby supplies very wide or compact title treatments.
                        .width(320.dp)
                        .height(96.dp),
                )
            } else {
                Text(
                    text = item.name,
                    color = Color.White,
                    fontSize = 46.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                    style = TextStyle(shadow = TextShadow),
                )
            }
            tagline?.let { tagline ->
                Text(
                    text = tagline,
                    color = Color(0xFFE4E8EC),
                    fontSize = 21.sp,
                    fontStyle = FontStyle.Italic,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                    style = TextStyle(shadow = TextShadow),
                )
            }
            MediaMetadata(item)

            // Keep the compact facts visually distinct from the plot synopsis.
            Spacer(Modifier.height(6.dp))

            StatusChips(item = item, isFavorite = isFavorite)

            item.overview?.takeIf { it.isNotBlank() }?.let {
                Text(
                    text = it,
                    color = Color(0xFFE4E8EC),
                    fontSize = 19.sp,
                    lineHeight = 26.sp,
                    maxLines = if (panelOpen) 6 else 4,
                    overflow = TextOverflow.Ellipsis,
                    style = TextStyle(shadow = TextShadow),
                )
            }

            AnimatedVisibility(
                visible = panelOpen,
                enter = fadeIn(),
                exit = fadeOut(),
            ) {
                Row(
                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
                    horizontalArrangement = Arrangement.spacedBy(14.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Button(onClick = onPlay, modifier = Modifier.focusRequester(playFocus)) {
                        Icon(Icons.Default.PlayArrow, contentDescription = null)
                        Text(text = "  Play trailer", modifier = Modifier.padding(start = 4.dp))
                    }
                    Button(onClick = onToggleFavorite) {
                        Icon(
                            imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
                            contentDescription = null,
                        )
                        Text(
                            text = if (isFavorite) "  Remove favourite" else "  Add favourite",
                            modifier = Modifier.padding(start = 4.dp),
                        )
                    }
                    Spacer(Modifier.weight(1f))
                    Button(onClick = onPrev) { Icon(Icons.Default.ChevronLeft, contentDescription = "Previous") }
                    Button(onClick = onNext) { Icon(Icons.Default.ChevronRight, contentDescription = "Next") }
                    Button(onClick = onOpenSettings) {
                        Icon(Icons.Default.Settings, contentDescription = "Settings")
                    }
                    Button(onClick = onExit) {
                        Icon(Icons.Default.Close, contentDescription = "Exit screensaver")
                    }
                }
            }

            if (!panelOpen) {
                Text(
                    text = "▲ options   ·   ◄ ► change   ·   ▶ play",
                    color = Color(0x99FFFFFF),
                    fontSize = 15.sp,
                    modifier = Modifier.padding(top = 6.dp).width(560.dp),
                )
            }
        }
    }
}

@Composable
private fun StatusChips(item: BaseItem, isFavorite: Boolean) {
    val chips = buildList {
        if (isFavorite) add("♥ Favourite")
        val ud = item.userData
        when {
            ud?.played == true -> add("✓ Watched")
            (ud?.playbackPositionTicks ?: 0L) > 0L -> add("▶ Resume")
        }
    }
    if (chips.isEmpty()) return
    Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
        chips.forEach { label ->
            Box(
                modifier = Modifier
                    .background(Color(0x33FFFFFF), RoundedCornerShape(6.dp))
                    .padding(horizontal = 12.dp, vertical = 5.dp),
            ) {
                Text(text = label, color = Color.White, fontSize = 14.sp)
            }
        }
    }
}

@Composable
private fun MessageScreen(text: String, onExit: () -> Unit) {
    val focus = remember { FocusRequester() }
    LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(
                Brush.linearGradient(
                    listOf(Color(0xFF17232B), Color(0xFF0B0E11), Color(0xFF101D17)),
                ),
            )
            .focusRequester(focus)
            .focusable()
            .onPreviewKeyEvent { event ->
                if (event.type == KeyEventType.KeyDown && event.key == Key.Back) { onExit(); true } else false
            },
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = text,
            color = Color.White,
            fontSize = 24.sp,
            modifier = Modifier.fillMaxWidth(0.6f).padding(48.dp),
        )
    }
}

/** Compact, icon-led metadata: the media type remains clear without a bulky text label. */
@Composable
private fun MediaMetadata(item: BaseItem) {
    val metadataColor = Color(0xFFC7CED4)
    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        Icon(
            imageVector = if (item.isSeries) Icons.Default.LiveTv else Icons.Default.Movie,
            contentDescription = if (item.isSeries) "Series" else "Movie",
            tint = metadataColor,
            modifier = Modifier.size(20.dp),
        )
        item.productionYear?.let { MetadataText(it.toString(), metadataColor) }
        item.communityRating?.let { MetadataText("★ ${"%.1f".format(it)}", metadataColor) }
        item.runtimeMinutes?.let {
            Icon(Icons.Default.AccessTime, contentDescription = "Runtime", tint = metadataColor, modifier = Modifier.size(18.dp))
            MetadataText("$it min", metadataColor)
        }
        item.studios.firstOrNull { it.name.isNotBlank() }?.name?.let {
            Icon(Icons.Default.Business, contentDescription = "Studio", tint = metadataColor, modifier = Modifier.size(18.dp))
            MetadataText(it, metadataColor)
        }
        item.genres.firstOrNull()?.takeIf { it.isNotBlank() }?.let {
            Icon(Icons.Default.LocalOffer, contentDescription = "Genre", tint = metadataColor, modifier = Modifier.size(18.dp))
            MetadataText(it, metadataColor)
        }
    }
}

@Composable
private fun MetadataText(text: String, color: Color) {
    Text(
        text = text,
        color = color,
        fontSize = 18.sp,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
        style = TextStyle(shadow = TextShadow),
    )
}
