@file:Suppress("TooManyFunctions") // Compose screen + private helper composables

package com.fabledsword.minstrel.playlists.ui

import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import androidx.navigation.toRoute
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEvent
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject

private const val SHARE_STOP_TIMEOUT_MS = 5_000L

// ─── State ───────────────────────────────────────────────────────────

sealed interface PlaylistDetailUiState {
    data class Loading(val seed: PlaylistRef? = null) : PlaylistDetailUiState
    data class Success(val detail: PlaylistDetailRef) : PlaylistDetailUiState
    data class Error(val message: String) : PlaylistDetailUiState
}

// ─── ViewModel ───────────────────────────────────────────────────────

@HiltViewModel
class PlaylistDetailViewModel @Inject constructor(
    private val repository: PlaylistsRepository,
    private val likes: LikesRepository,
    private val player: PlayerController,
    private val eventsStream: EventsStream,
    private val seedCache: DetailSeedCache,
    networkStatus: NetworkStatusController,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val route: PlaylistDetail = savedStateHandle.toRoute()
    val playlistId: String = route.id

    private val internal =
        MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading())
    val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()

    /**
     * One-shot signal: this playlist was deleted (server-side or from
     * another client). The screen collects this and pops back so the
     * user doesn't sit on a now-404 detail.
     */
    private val deletedChannel = Channel<Unit>(Channel.BUFFERED)
    val deleted: Flow<Unit> = deletedChannel.receiveAsFlow()

    /**
     * One-shot signal: a system playlist regenerate landed and the
     * server rotated the uuid. Screen replaces the current detail
     * route with the new id so the user sees the fresh list.
     */
    private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
    val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()

    /**
     * #980: a system-playlist rebuild landed (SSE) while this screen is open —
     * its list is now stale and the uuid has rotated. Drives a "this mix was
     * refreshed → Refresh" snackbar rather than yanking the user; Refresh
     * re-resolves the variant via [reloadRebuilt].
     */
    private val staleInternal = MutableStateFlow(false)
    val stale: StateFlow<Boolean> = staleInternal.asStateFlow()

    val likedTrackIds: StateFlow<Set<String>> =
        likes.observeLikedTrackIds()
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
                initialValue = emptySet(),
            )

    init {
        refresh()
        viewModelScope.launch {
            eventsStream.events
                .filter { it.kind.startsWith("playlist.") }
                .collect { handlePlaylistEvent(it) }
        }
        // Screen-level auto-recovery (issue #1245): a detail that failed to
        // load while the server was unreachable re-fetches when health
        // returns, instead of waiting for the Retry tap.
        viewModelScope.launch {
            networkStatus.recoveries().collect {
                if (internal.value is PlaylistDetailUiState.Error) refresh()
            }
        }
    }

    /**
     * Filters playlist.* events to those touching this screen's id.
     * delete → pop the screen; updated / tracks_changed → re-fetch.
     * Other kinds (e.g. playlist.created for a different list) are
     * ignored — the playlists-list screen handles those.
     */
    private fun handlePlaylistEvent(event: LiveEvent) {
        // #980: a system rebuild carries no playlist_id (it rebuilds all of the
        // user's system mixes at once) — handle it before the per-id filter.
        if (event.kind == "playlist.system_rebuilt") {
            markStaleIfSystem()
            return
        }
        val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
        if (eventPlaylistId != playlistId) return
        when (event.kind) {
            "playlist.deleted" -> deletedChannel.trySend(Unit)
            "playlist.updated", "playlist.tracks_changed" -> refresh()
        }
    }

    private fun markStaleIfSystem() {
        val playlist = (internal.value as? PlaylistDetailUiState.Success)
            ?.detail?.playlist ?: return
        if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
            staleInternal.value = true
        }
    }

    fun toggleLikeTrack(trackId: String) {
        val desired = trackId !in likedTrackIds.value
        viewModelScope.launch {
            likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
        }
    }

    fun refresh(): Job = viewModelScope.launch {
        try {
            val carried = when (val cur = internal.value) {
                is PlaylistDetailUiState.Loading -> cur.seed
                is PlaylistDetailUiState.Success -> cur.detail.playlist
                is PlaylistDetailUiState.Error -> null
            }
            internal.value = PlaylistDetailUiState.Loading(
                carried ?: seedCache.peekPlaylist(playlistId),
            )
            val detail = repository.refreshDetail(playlistId)
            internal.value = PlaylistDetailUiState.Success(detail)
        } catch (
            @Suppress("TooGenericExceptionCaught") e: Throwable,
        ) {
            internal.value = PlaylistDetailUiState.Error(
                ErrorCopy.fromThrowable(e),
            )
        }
    }

    /**
     * Server-side rebuild of this system playlist. No-op when the
     * playlist isn't a refreshable system variant. On success the
     * server rotates the playlist uuid; the new id arrives on
     * [regenerated] for the screen to navigate-replace to.
     */
    fun regenerate() {
        val playlist = (internal.value as? PlaylistDetailUiState.Success)
            ?.detail?.playlist ?: return
        val variant = playlist.systemVariant
            ?.takeIf { playlist.refreshable } ?: return
        viewModelScope.launch {
            runCatching { repository.refreshSystemPlaylist(variant) }
                .onSuccess { newId ->
                    if (!newId.isNullOrEmpty()) {
                        regeneratedChannel.trySend(newId)
                    }
                }
        }
    }

    /**
     * #980: user tapped Refresh on the stale-view snackbar. The rebuild
     * already ran server-side (uuid rotated) — re-resolve the variant to the
     * fresh playlist id and hand it to [regenerated] for navigate-replace.
     * Unlike [regenerate] this does NOT trigger another rebuild.
     */
    fun reloadRebuilt() {
        val playlist = (internal.value as? PlaylistDetailUiState.Success)
            ?.detail?.playlist ?: return
        val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
        staleInternal.value = false
        viewModelScope.launch {
            runCatching { repository.systemShuffle(variant).playlist.id }
                .onSuccess { newId ->
                    if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
                }
        }
    }

    /** #980: user dismissed the stale-view snackbar without refreshing. */
    fun dismissStale() {
        staleInternal.value = false
    }

    /** Play the available tracks starting at [startTrackId] (or first available). */
    fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
        val refs = tracks.toPlayableTrackRefs()
        if (refs.isEmpty()) return
        val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0)
        // Drift #564: for refreshable system playlists, send the bare
        // systemVariant string so the rotation matcher
        // (systemPlaylistSources in playevents/writer.go) sees the
        // play. User playlists keep the "playlist:<id>" tag for
        // attribution but it doesn't trigger rotation (intentional —
        // rotation only applies to system mixes).
        val playlist = (internal.value as? PlaylistDetailUiState.Success)?.detail?.playlist
        val source = playlist?.systemVariant?.takeIf { playlist.refreshable }
            ?: "playlist:$playlistId"
        player.setQueue(refs, initialIndex = startIndex, source = source)
    }
}

// ─── Screen ──────────────────────────────────────────────────────────

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistDetailScreen(
    navController: NavHostController,
    viewModel: PlaylistDetailViewModel = hiltViewModel(),
    playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
    val state by viewModel.uiState.collectAsState()
    LaunchedEffect(Unit) {
        viewModel.deleted.collect { navController.popBackStack() }
    }
    LaunchedEffect(Unit) {
        viewModel.regenerated.collect { newId ->
            // Replace the current detail route with the regenerated
            // playlist's new uuid; pop the old entry so back doesn't
            // land on a 404'd page.
            navController.navigate(PlaylistDetail(newId)) {
                popUpTo(PlaylistDetail(viewModel.playlistId)) { inclusive = true }
            }
        }
    }
    // #980: a rebuild landed while this system-playlist screen is open. Offer a
    // non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
    // rotated uuid (via the regenerated navigate-replace flow).
    val snackbarHostState = remember { SnackbarHostState() }
    val stale by viewModel.stale.collectAsState()
    LaunchedEffect(stale) {
        if (stale) {
            val result = snackbarHostState.showSnackbar(
                message = "This mix was refreshed",
                actionLabel = "Refresh",
                duration = SnackbarDuration.Indefinite,
            )
            if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
            else viewModel.dismissStale()
        }
    }
    Scaffold(
        contentWindowInsets = ShellContentWindowInsets,
        modifier = Modifier.fillMaxSize(),
        snackbarHost = { SnackbarHost(snackbarHostState) },
        topBar = {
            TopAppBar(
                title = { Text(currentTitle(state)) },
                navigationIcon = {
                    IconButton(onClick = { navController.popBackStack() }) {
                        Icon(Lucide.ArrowLeft, contentDescription = "Back")
                    }
                },
            )
        },
    ) { inner ->
        PullToRefreshScaffold(
            onRefresh = { viewModel.refresh().join() },
            modifier = Modifier.fillMaxSize().padding(inner),
        ) {
            PlaylistDetailContent(
                state = state,
                viewModel = viewModel,
                playerViewModel = playerViewModel,
                navController = navController,
            )
        }
    }
}

@Composable
private fun PlaylistDetailContent(
    state: PlaylistDetailUiState,
    viewModel: PlaylistDetailViewModel,
    playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
    navController: NavHostController,
) {
    // Crossfade keyed on `state::class` so refreshes that emit a
    // fresh Success (same kind, new detail) don't re-fade the body;
    // only Loading ↔ Success ↔ Error transitions animate.
    Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
        is PlaylistDetailUiState.Loading ->
            s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
        is PlaylistDetailUiState.Error -> ErrorRetry(
            title = "Couldn't load playlist",
            message = s.message,
            onRetry = { viewModel.refresh() },
        )
        is PlaylistDetailUiState.Success -> {
            val likedTrackIds by viewModel.likedTrackIds.collectAsState()
            val playerState by playerViewModel.uiState.collectAsState()
            PlaylistDetailBody(
                detail = s.detail,
                likedTrackIds = likedTrackIds,
                playingTrackId = playerState.currentTrack?.id,
                onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
                onShuffleAll = {
                    viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
                },
                onRegenerate = viewModel::regenerate,
                onTrackClick = { id ->
                    viewModel.play(s.detail.tracks, startTrackId = id)
                },
                onToggleTrackLike = viewModel::toggleLikeTrack,
                onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
                onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
            )
        }
    } }
}

private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
    is PlaylistDetailUiState.Success -> state.detail.playlist.name
    is PlaylistDetailUiState.Loading -> state.seed?.name ?: "Playlist"
    is PlaylistDetailUiState.Error -> "Playlist"
}

// ─── Body composables ────────────────────────────────────────────────

@Composable
private fun PlaylistDetailBody(
    detail: PlaylistDetailRef,
    likedTrackIds: Set<String>,
    playingTrackId: String?,
    onPlayAll: () -> Unit,
    onShuffleAll: () -> Unit,
    onRegenerate: () -> Unit,
    onTrackClick: (String) -> Unit,
    onToggleTrackLike: (String) -> Unit,
    onNavigateToAlbum: (String) -> Unit,
    onNavigateToArtist: (String) -> Unit,
) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
    ) {
        item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll, onRegenerate) }
        item { HorizontalDivider() }
        items(items = detail.tracks, key = { it.position }) { row ->
            TrackRow(
                row = row,
                liked = row.trackId != null && row.trackId in likedTrackIds,
                nowPlaying = row.trackId != null && row.trackId == playingTrackId,
                onClick = { row.trackId?.let(onTrackClick) },
                onToggleLike = { row.trackId?.let(onToggleTrackLike) },
                onNavigateToAlbum = onNavigateToAlbum,
                onNavigateToArtist = onNavigateToArtist,
            )
            HorizontalDivider()
        }
        if (detail.tracks.isEmpty()) {
            item {
                Text(
                    text = "No tracks in this playlist yet.",
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    style = MaterialTheme.typography.bodyMedium,
                    modifier = Modifier.padding(24.dp),
                )
            }
        }
    }
}

@Composable
private fun PlaylistHeader(
    playlist: PlaylistRef,
    onPlayAll: () -> Unit,
    onShuffleAll: () -> Unit,
    onRegenerate: () -> Unit,
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            PlaylistCover(playlist)
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = playlist.name,
                    style = MaterialTheme.typography.headlineSmall,
                    color = MaterialTheme.colorScheme.onBackground,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
                if (playlist.description.isNotEmpty()) {
                    Text(
                        text = playlist.description,
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        maxLines = 3,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
                Spacer(Modifier.height(4.dp))
                Text(
                    text = "${playlist.trackCount} tracks",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
        }
        PlaylistHeaderActions(
            refreshable = playlist.refreshable,
            onPlayAll = onPlayAll,
            onShuffleAll = onShuffleAll,
            onRegenerate = onRegenerate,
        )
    }
}

@Composable
private fun PlaylistHeaderActions(
    refreshable: Boolean,
    onPlayAll: () -> Unit,
    onShuffleAll: () -> Unit,
    onRegenerate: () -> Unit,
) {
    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(onClick = onPlayAll, colors = ButtonDefaults.buttonColors()) {
            Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
            Spacer(Modifier.width(8.dp))
            Text("Play")
        }
        OutlinedButton(onClick = onShuffleAll) {
            Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp))
            Spacer(Modifier.width(8.dp))
            Text("Shuffle")
        }
        if (refreshable) {
            OutlinedButton(onClick = onRegenerate) {
                Icon(Lucide.RefreshCw, contentDescription = null, modifier = Modifier.size(18.dp))
                Spacer(Modifier.width(8.dp))
                Text("Regenerate")
            }
        }
    }
}

@Composable
private fun PlaylistCover(playlist: PlaylistRef) {
    Box(
        modifier = Modifier
            .size(120.dp)
            .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
            .background(MaterialTheme.colorScheme.surfaceVariant),
        contentAlignment = Alignment.Center,
    ) {
        if (playlist.coverUrl.isEmpty()) {
            Icon(
                imageVector = Lucide.ListMusic,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.size(56.dp),
            )
        } else {
            AsyncImage(
                model = playlist.coverUrl,
                contentDescription = playlist.name,
                modifier = Modifier.fillMaxSize(),
            )
        }
    }
}

@Composable
private fun TrackRow(
    row: PlaylistTrackRef,
    liked: Boolean,
    nowPlaying: Boolean,
    onClick: () -> Unit,
    onToggleLike: () -> Unit,
    onNavigateToAlbum: (String) -> Unit,
    onNavigateToArtist: (String) -> Unit,
) {
    val enabled = row.isAvailable
    val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
    TrackRow(
        title = row.title,
        artist = row.artistName.ifEmpty { row.albumTitle },
        trackId = row.trackId.orEmpty(),
        onClick = onClick,
        nowPlaying = nowPlaying,
        enabled = enabled,
        contentAlpha = rowAlpha,
        leading = {
            TrackCoverThumb(
                coverUrl = row.coverUrl,
                contentDescription = row.title,
                modifier = Modifier.padding(end = 12.dp),
            )
        },
        trailing = {
            Text(
                text = formatDuration(row.durationSec, zero = "--:--"),
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha),
                modifier = Modifier.padding(start = 12.dp),
            )
            if (enabled) {
                LikeButton(liked = liked, onToggle = onToggleLike)
                TrackActionsButton(
                    track = row.toTrackRef(),
                    onNavigateToAlbum = onNavigateToAlbum,
                    onNavigateToArtist = onNavigateToArtist,
                )
            }
        },
    )
}

private const val SKELETON_PLAYLIST_ROWS = 8

@Composable
private fun SkeletonPlaylistTrackList() {
    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(SKELETON_PLAYLIST_ROWS) { SkeletonTrackRow() }
    }
}

/**
 * Loading body that renders the playlist header from the navigation
 * seed (cover + name + description + count) with skeleton track rows
 * below. Play / Shuffle / Regenerate omitted — they need the loaded
 * track list.
 */
@Composable
private fun SeededPlaylistLoading(seed: PlaylistRef) {
    LazyColumn(modifier = Modifier.fillMaxSize()) {
        item {
            Row(
                modifier = Modifier.fillMaxWidth().padding(16.dp),
                horizontalArrangement = Arrangement.spacedBy(16.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                PlaylistCover(seed)
                Column(modifier = Modifier.weight(1f)) {
                    Text(
                        text = seed.name,
                        style = MaterialTheme.typography.headlineSmall,
                        color = MaterialTheme.colorScheme.onBackground,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                    )
                    if (seed.description.isNotEmpty()) {
                        Text(
                            text = seed.description,
                            style = MaterialTheme.typography.bodyMedium,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                            maxLines = 3,
                            overflow = TextOverflow.Ellipsis,
                        )
                    }
                    Spacer(Modifier.height(4.dp))
                    Text(
                        text = "${seed.trackCount} tracks",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                }
            }
        }
        item { HorizontalDivider() }
        items(SKELETON_PLAYLIST_ROWS) { SkeletonTrackRow() }
    }
}

// ─── Helpers ─────────────────────────────────────────────────────────

private const val UNAVAILABLE_ALPHA = 0.4f

/**
 * Lossy mapping: PlaylistTrackRef → TrackRef so the player can take it.
 * Caller must filter `isAvailable && streamUrl != null` first.
 */
private fun PlaylistTrackRef.toTrackRef(): TrackRef = TrackRef(
    id = trackId.orEmpty(),
    title = title,
    albumId = albumId.orEmpty(),
    artistId = artistId.orEmpty(),
    albumTitle = albumTitle,
    artistName = artistName,
    durationSec = durationSec,
    streamUrl = streamUrl.orEmpty(),
)
