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

package com.fabledsword.minstrel.home.ui

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.HomeTile
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.UpdateVeilController
import com.fabledsword.minstrel.shared.VeilOutcome
import com.fabledsword.minstrel.shared.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
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.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject

private const val SHARE_STOP_TIMEOUT_MS = 5_000L
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades
// a tall block for fewer columns to scroll past — a 200dp tile × 2 =
// ~440dp section still leaves room for the rest of Home.
private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440

// "Updating your mixes…" veil. UpdateVeilController decides both whether it
// appears at all — only when a refresh actually changes something — and how
// long it stays, by watching the screen settle rather than by a fixed delay,
// which lowered it while tiles and artwork were still landing (#2327).
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f
private const val VEIL_SPINNER_DP = 22
private const val VEIL_SPINNER_STROKE_DP = 2
private const val VEIL_LABEL_GAP_DP = 12

// Backstop on how long a manual pull keeps its own indicator while waiting
// for its successor — the veil, or the "already up to date" snackbar — so the
// two never both vanish for a frame mid-handoff.
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L

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

data class HomeSections(
    val playlists: List<PlaylistRef> = emptyList(),
    val recentlyAddedAlbums: List<HomeTile<AlbumRef>> = emptyList(),
    val rediscoverAlbums: List<HomeTile<AlbumRef>> = emptyList(),
    val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
    val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
    val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
    val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
    val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
) {
    val isAllEmpty: Boolean
        get() = playlists.isEmpty() &&
            recentlyAddedAlbums.isEmpty() &&
            rediscoverAlbums.isEmpty() &&
            rediscoverArtists.isEmpty() &&
            mostPlayedTracks.isEmpty() &&
            lastPlayedArtists.isEmpty() &&
            youMightLikeAlbums.isEmpty() &&
            youMightLikeArtists.isEmpty()
}

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

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val homeRepository: HomeRepository,
    private val playlistsRepository: PlaylistsRepository,
    private val libraryRepository: LibraryRepository,
    private val player: com.fabledsword.minstrel.player.PlayerController,
    private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
    private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
    networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) : ViewModel() {

    private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())

    /** System-playlist build status for the Home placeholder cards. */
    val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()

    /**
     * Cache-only when there's no link OR the server is unreachable. Reads the
     * unified [NetworkStatusController] (not the raw device link) so Home reacts
     * to ServerDown too; the transient Unstable state stays calm (not offline).
     */
    val offline: StateFlow<Boolean> = networkStatus.state
        .map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
            initialValue = false,
        )

    private val snackbarMessages = Channel<String>(Channel.BUFFERED)

    /**
     * Transient snackbar messages: offline-pool taps, playback failures, and
     * the outcome of a refresh the user explicitly asked for.
     */
    val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()

    /**
     * Copy for the most recent /home/index refresh failure; null once a
     * refresh succeeds. Folded into [uiState] so an empty cache + failed
     * refresh renders as Error (with retry) instead of masquerading as
     * the "Welcome to Minstrel" empty state.
     */
    private val refreshError = MutableStateFlow<String?>(null)

    /**
     * Cover-art loads in flight on Home, reported by every [ServerImage]
     * under [LocalArtSettleTracker]. The veil waits on this so artwork
     * arriving a beat after its tile lands behind the veil rather than
     * popping in on screen.
     */
    val artTracker = ArtSettleTracker()

    /**
     * Tap an offline pool: shuffle + play its cached tracks. Empty
     * pool surfaces a snackbar rather than starting silent playback.
     */
    fun playPool(kind: OfflinePoolKind) {
        viewModelScope.launch {
            val tracks = when (kind) {
                OfflinePoolKind.RECENTLY_PLAYED -> shuffleSource.recentlyPlayed()
                OfflinePoolKind.LIKED -> shuffleSource.liked()
            }.shuffled()
            if (tracks.isEmpty()) {
                snackbarMessages.trySend("No cached ${kind.label} tracks yet")
            } else {
                player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
            }
        }
    }

    /**
     * Tap on a Most-played tile: replace the queue with the section's
     * tracks and start at the tapped index. Mirrors Flutter's
     * `_resolveSectionTracks` — Home's section is itself the playable
     * unit, not a list of album-detail links.
     */
    fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) {
        if (tracks.isEmpty()) return
        player.setQueue(
            tracks = tracks,
            initialIndex = startIndex.coerceIn(0, tracks.lastIndex),
            source = "home:most_played",
        )
    }

    /**
     * Tap the album play-button overlay: fetch the album detail and
     * play its tracks from the first. Mirrors Flutter's AlbumCard
     * `_playAlbum`. Suspending so the button's spinner waits for the
     * fetch + queue setup; the inner work is launched in viewModelScope
     * so cancellation from leaving the screen mid-fetch doesn't kill
     * playback. Errors surface via the snackbar channel (Android
     * improvement over Flutter's silent-fail; matches the existing
     * ArtistDetail play handler).
     */
    suspend fun playAlbum(albumId: String) {
        viewModelScope.launch {
            try {
                val detail = libraryRepository.refreshAlbumDetail(albumId)
                if (detail.tracks.isEmpty()) {
                    snackbarMessages.trySend("This album has no tracks to play.")
                } else {
                    player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
                }
            } catch (
                @Suppress("TooGenericExceptionCaught") e: Throwable,
            ) {
                snackbarMessages.trySend(
                    "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
                )
            }
        }.join()
    }

    /**
     * Tap the artist play-button overlay: fetch every track across the
     * artist's albums, Fisher-Yates shuffle, play from index 0. Mirrors
     * Flutter's ArtistCard `_playArtistShuffle`. Empty + error semantics
     * follow [playAlbum].
     */
    suspend fun playArtistShuffled(artistId: String) {
        viewModelScope.launch {
            try {
                val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
                if (tracks.isEmpty()) {
                    snackbarMessages.trySend("This artist has no tracks to play.")
                } else {
                    player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
                }
            } catch (
                @Suppress("TooGenericExceptionCaught") e: Throwable,
            ) {
                snackbarMessages.trySend(
                    "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
                )
            }
        }.join()
    }

    /**
     * Tap the playlist play-button overlay: fetch the playlist's tracks
     * (rotation-aware shuffle for refreshable system playlists, stored
     * order for everything else), 8s timeout, play from index 0.
     * Mirrors Flutter PlaylistCard `_playPlaylist`. Empty-mix and
     * timeout get their own copy strings; other errors fall through to
     * [ErrorCopy.fromThrowable].
     */
    suspend fun playPlaylist(playlist: PlaylistRef) {
        viewModelScope.launch {
            playPlaylistShuffled(playlist, playlistsRepository, player) {
                snackbarMessages.trySend(it)
            }
        }.join()
    }

    /**
     * Pulls /home/index, the playlists list and the system-playlist
     * status. Returns true when the load-bearing /home/index pull
     * succeeded — the veil controller retries on false and reports the
     * outcome, so this must report failure rather than swallow it.
     */
    private suspend fun runRefresh(): Boolean = coroutineScope {
        // /home/index is the load-bearing pull: its failure drives the
        // empty-cache Error state. A failure over a populated cache
        // stays silent — cached sections beat a full-screen error.
        //
        // Cleared on success, NOT at the start of each attempt: with the
        // veil's retries, clearing up front made a failing cold start
        // flash the "Welcome to Minstrel" empty state (empty cache + no
        // error reads as Empty) between one attempt and the next.
        val home = async {
            runCatching { homeRepository.refreshIndex() }
                .onSuccess { refreshError.value = null }
                .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
                .isSuccess
        }
        val lists = launch { runCatching { playlistsRepository.refreshList() } }
        val status = launch {
            runCatching { homeRepository.getSystemPlaylistsStatus() }
                .onSuccess { systemStatusInternal.value = it }
        }
        lists.join()
        status.join()
        home.await()
    }

    /**
     * The Error state's explicit Retry button. User-initiated, so it gets
     * the controller's retries and reports its outcome; over an empty cache
     * there's no content to protect, so no veil goes up.
     */
    fun retry() = veil.request(userInitiated = true)

    /**
     * Manual pull-to-refresh. Goes behind the veil like every other refresh
     * (operator call, 2026-07-31: the churn a pull causes is identical to
     * the automatic paths, and a small spinner didn't hide it).
     *
     * Suspends until the veil has taken over OR the session has finished,
     * so the pull indicator hands off to exactly one successor: the veil if
     * content changed, the "Already up to date" snackbar if it didn't. The
     * timeout is only a backstop against a session that outlives it.
     */
    suspend fun refreshFromPull() {
        val before = veil.finishedSessions.value
        veil.request(userInitiated = true)
        withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
            combine(veil.visible, veil.finishedSessions) { veiled, finished ->
                veiled || finished != before
            }.first { it }
        }
    }

    val uiState: StateFlow<UiState<HomeSections>> =
        combineHomeFlows().asCacheFirstStateFlow(viewModelScope)

    /**
     * The core five section flows merged into the sections struct. Split
     * out so each combine call stays inside the coroutines built-in arity
     * limit (5) and avoids the typed-vararg acrobatics a wider combine
     * across heterogeneous types would need. [observeHomeSections] then
     * folds in the two you-might-like flows, and [combineHomeFlows] the
     * playlists flow.
     */
    private fun observeCoreSections() = combine(
        homeRepository.observeRecentlyAddedAlbums(),
        homeRepository.observeRediscoverAlbums(),
        homeRepository.observeRediscoverArtists(),
        homeRepository.observeMostPlayedTracks(),
        homeRepository.observeLastPlayedArtists(),
    ) { recent, rediscoverAlbums, rediscoverArtists, mostPlayed, lastPlayed ->
        HomeSections(
            recentlyAddedAlbums = recent,
            rediscoverAlbums = rediscoverAlbums,
            rediscoverArtists = rediscoverArtists,
            mostPlayedTracks = mostPlayed,
            lastPlayedArtists = lastPlayed,
        )
    }

    private fun observeHomeSections() = combine(
        observeCoreSections(),
        homeRepository.observeYouMightLikeAlbums(),
        homeRepository.observeYouMightLikeArtists(),
    ) { core, ymlAlbums, ymlArtists ->
        core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
    }

    private fun combineHomeFlows() =
        combine(
            observeHomeSections(),
            playlistsRepository.observeAll(),
            refreshError,
        ) { sections, playlists, err ->
            val merged = sections.copy(playlists = playlists)
            when {
                !merged.isAllEmpty -> UiState.Success(merged)
                err != null -> UiState.Error(err)
                else -> UiState.Empty
            }
        }

    // ─── Updating veil ───────────────────────────────────────────────
    // Declared after uiState: these initialisers read it, and Kotlin runs
    // property initialisers and init blocks in declaration order.

    /**
     * What the veil watches to decide Home has stopped moving: the whole
     * rendered state, plus how many covers are still loading.
     *
     * [UiState.Success] wraps a [HomeSections] data class, so any visible
     * change — a section swapping ids, one tile hydrating from skeleton to
     * album — changes this value and re-arms the veil's quiet window.
     *
     * Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
     * on-miss fetch soft-fails keeps a null value indefinitely
     * ([MetadataProvider] swallows those errors), so treating "no
     * skeletons left" as the settle condition would pin the veil to its
     * hard ceiling on every refresh. They're covered by the content key
     * instead: each tile that lands re-arms the window, and once they stop
     * landing the screen is genuinely still.
     */
    private val settleSignal: Flow<VeilSettleState> =
        combine(uiState, artTracker.inFlight) { state, artInFlight ->
            VeilSettleState(
                contentKey = state,
                hasContent = state is UiState.Success,
                quiescent = artInFlight == 0,
            )
        }

    private val veil = UpdateVeilController(
        scope = viewModelScope,
        settleSignal = settleSignal,
        shouldVeil = {
            // Only worth hiding churn when there's already content to
            // hide. A cold load over an empty cache keeps its skeleton —
            // veiling that would replace a useful affordance with an
            // opaque panel. `hasCachedIndex` is the honest check: uiState
            // still reads Loading until the screen subscribes, so on a
            // process restore over a warm cache it would say "no content"
            // right before the cache emits.
            uiState.value is UiState.Success || homeRepository.hasCachedIndex()
        },
        onSessionEnd = ::reportRefreshOutcome,
        work = ::runRefresh,
    )

    /**
     * Tells the user how a refresh *they asked for* went, in the one case
     * the veil can't: when nothing changed there's no veil to see, and a
     * pull that produces no visible response at all reads as broken.
     *
     * Only user-initiated sessions say anything. The same outcome from a
     * background check — the initial load, the 03:00 rebuild, a reconnect —
     * is noise, and "Already up to date" on every launch would be worse
     * than silence (operator's call, 2026-07-31).
     */
    private fun reportRefreshOutcome(result: VeilSessionResult) {
        if (!result.userInitiated) return
        when (result.outcome) {
            // The veil was the feedback.
            VeilOutcome.CHANGED -> return
            VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
            VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
        }
    }

    /**
     * True while the "Updating your mixes…" veil should be raised.
     *
     * Raised only when a refresh actually changes what's on screen, and then
     * held until Home settles — tiles hydrated, artwork loaded — instead of
     * for a fixed delay after the network pull returns (issue #2327). A
     * refresh that returns what's already cached shows no veil at all;
     * [reportRefreshOutcome] tells the user instead, if they asked.
     */
    val isUpdating: StateFlow<Boolean> = veil.visible

    init {
        // Every refresh path goes through the controller, which decides
        // per session whether to raise the veil. That includes the initial
        // load: over a warm cache it's a full re-pull that churns every
        // section, and it used to run completely unveiled.
        veil.request()
        // Screen-level auto-recovery (issue #1245): a Home that failed to
        // load while the server was unreachable re-pulls itself the moment
        // health returns — same idiom as SyncController, one layer up.
        // This is also the recovery that keeps trying after the veil has
        // given up and lowered; the controller sets no latch against it.
        viewModelScope.launch {
            networkStatus.recoveries().collect { veil.request() }
        }
        // Server-side changes that rewrite what Home renders (#968 and
        // the 2026-07-31 widening) re-pull behind the veil. Mirrors the
        // web SSE consumer.
        viewModelScope.launch {
            eventsStream.events
                .filter { it.kind in VEILED_EVENT_KINDS }
                .collect { veil.request() }
        }
    }

    private companion object {
        /**
         * Events that change what Home shows. `playlist.system_rebuilt`
         * is the 03:00 daily rebuild; the other `playlist.*` kinds move
         * the Playlists and Songs-like rows; `scan.run_finished` changes
         * Recently added (and Home never reacted to it at all before).
         */
        private val VEILED_EVENT_KINDS = setOf(
            "playlist.system_rebuilt",
            "playlist.created",
            "playlist.updated",
            "playlist.deleted",
            "playlist.tracks_changed",
            "scan.run_finished",
        )
    }
}

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

@Composable
fun HomeScreen(
    navController: NavHostController,
    viewModel: HomeViewModel = hiltViewModel(),
) {
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(Unit) {
        viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
    }
    Scaffold(
        contentWindowInsets = ShellContentWindowInsets,
        modifier = Modifier.fillMaxSize(),
        topBar = {
            MinstrelTopAppBar(
                title = "Minstrel",
                navController = navController,
                currentRouteName = Home::class.qualifiedName,
            )
        },
        snackbarHost = { SnackbarHost(snackbarHostState) },
    ) { inner ->
        val state by viewModel.uiState.collectAsStateWithLifecycle()
        val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
        val offline by viewModel.offline.collectAsStateWithLifecycle()
        val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
        PullToRefreshScaffold(
            onRefresh = { viewModel.refreshFromPull() },
            modifier = Modifier.fillMaxSize().padding(inner),
        ) {
            Box(Modifier.fillMaxSize()) {
                // Every cover below reports its load state to the tracker,
                // so the veil can wait for artwork instead of guessing.
                CompositionLocalProvider(
                    LocalArtSettleTracker provides viewModel.artTracker,
                ) {
                    HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
                }
                // Refresh veil: rebuild / reconnect / pull / event churn all
                // hide behind an "Updating your mixes…" wipe that stays up
                // until the screen has actually stopped moving.
                UpdatingVeil(visible = updating)
            }
        }
    }
}

/**
 * The Loading/Empty/Error/Success switch for Home, crossfaded on state.
 *
 * Key the Crossfade on the state CLASS, not the instance. Each section
 * emission produces a new UiState.Success(data); if we keyed on `state`
 * directly, every per-section hydration tick would re-run the 300ms
 * crossfade, and first-sign-in (six sections cascading in) reads as
 * continuous flicker. Keying on the class restricts the animation to
 * Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
 * recompositions update the LazyColumn without a fade.
 */
@Composable
private fun HomeStateCrossfade(
    state: UiState<HomeSections>,
    systemStatus: SystemPlaylistsStatus,
    offline: Boolean,
    navController: NavHostController,
    viewModel: HomeViewModel,
) {
    Crossfade(targetState = state::class, label = "home-state") { _ ->
        when (val s = state) {
            UiState.Loading -> HomeSkeletonContent()
            UiState.Empty -> EmptyState(
                title = "Welcome to Minstrel",
                body = "Nothing to show yet — scan a folder in your server " +
                    "settings, then come back here for system playlists " +
                    "and recommendations.",
            )
            is UiState.Error -> ErrorRetry(
                title = "Couldn't load home",
                message = s.message,
                onRetry = { viewModel.retry() },
            )
            is UiState.Success -> HomeSuccessContent(
                sections = s.data,
                systemStatus = systemStatus,
                offline = offline,
                onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
                onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
                onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
                onMostPlayedTap = viewModel::playMostPlayed,
                onPlayPool = viewModel::playPool,
                onPlayAlbum = viewModel::playAlbum,
                onPlayArtist = viewModel::playArtistShuffled,
                onPlayPlaylist = viewModel::playPlaylist,
            )
        }
    }
}

/**
 * Full-bleed "Updating your mixes…" veil that wipes in from the left,
 * holds while an automatic refresh repopulates Home, then wipes off.
 * Near-opaque so the section churn underneath never shows; swallows taps
 * while raised so a mid-hydration tile can't be hit.
 */
@Composable
private fun BoxScope.UpdatingVeil(visible: Boolean) {
    AnimatedVisibility(
        visible = visible,
        enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
        exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
        modifier = Modifier.matchParentSize(),
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
                .clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null,
                ) {},
            contentAlignment = Alignment.Center,
        ) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                CircularProgressIndicator(
                    modifier = Modifier.size(VEIL_SPINNER_DP.dp),
                    strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
                    color = MaterialTheme.colorScheme.primary,
                )
                Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
                Text(
                    text = "Updating your mixes…",
                    style = MaterialTheme.typography.titleMedium,
                    color = MaterialTheme.colorScheme.onBackground,
                )
            }
        }
    }
}

/**
 * Skeleton stand-in shown during cold-load. Mirrors the success
 * layout's section structure (Playlists carousel → Recently added
 * carousel → Rediscover albums carousel → Last played artists
 * carousel) so the Crossfade reveal doesn't reflow.
 */
@Composable
private fun HomeSkeletonContent() {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(vertical = 8.dp),
    ) {
        item { SkeletonSectionHeader() }
        item { SkeletonAlbumRow() }
        item { SkeletonSectionHeader() }
        item { SkeletonAlbumRow() }
        item { SkeletonSectionHeader() }
        item { SkeletonArtistRow() }
        item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
    }
}

private const val SKELETON_TILES_PER_ROW = 5

@Composable
private fun SkeletonAlbumRow() {
    LazyRow(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(horizontal = 8.dp),
    ) {
        items(SKELETON_TILES_PER_ROW) { SkeletonAlbumTile() }
    }
}

@Composable
private fun SkeletonArtistRow() {
    LazyRow(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(horizontal = 8.dp),
    ) {
        items(SKELETON_TILES_PER_ROW) { SkeletonArtistTile() }
    }
}

@Composable
private fun HomeSuccessContent(
    sections: HomeSections,
    systemStatus: SystemPlaylistsStatus,
    offline: Boolean,
    onAlbumClick: (String) -> Unit,
    onArtistClick: (String) -> Unit,
    onPlaylistClick: (String) -> Unit,
    onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
    onPlayPool: (OfflinePoolKind) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
    onPlayArtist: suspend (String) -> Unit,
    onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(vertical = 8.dp),
    ) {
        item {
            // Always rendered: real system/user playlists, with
            // placeholder cards filling the For You / Discover slots
            // that haven't generated yet. When offline, the cache-backed
            // pool cards lead the row. Songs-like now lives in its own
            // dedicated row below (#1491), no longer inside this carousel.
            PlaylistsRow(
                title = "Playlists",
                rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
                offline = offline,
                onPlaylistClick = onPlaylistClick,
                onPlayPool = onPlayPool,
                onPlayPlaylist = onPlayPlaylist,
            )
        }
        // Songs-like is the best-performing surface (#1491) — promoted out
        // of the Playlists carousel into its own row so it shows a wider
        // spread of "Songs like {artist}" mixes. Hidden when there's
        // nothing to show (offline with none cached).
        songsLikeSection(
            playlists = sections.playlists,
            status = systemStatus,
            offline = offline,
            onPlaylistClick = onPlaylistClick,
            onPlayPool = onPlayPool,
            onPlayPlaylist = onPlayPlaylist,
        )
        youMightLikeSection(
            albums = sections.youMightLikeAlbums,
            artists = sections.youMightLikeArtists,
            onAlbumClick = onAlbumClick,
            onArtistClick = onArtistClick,
            onPlayAlbum = onPlayAlbum,
            onPlayArtist = onPlayArtist,
        )
        recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
        rediscoverSection(
            albums = sections.rediscoverAlbums,
            artists = sections.rediscoverArtists,
            onAlbumClick = onAlbumClick,
            onArtistClick = onArtistClick,
            onPlayAlbum = onPlayAlbum,
            onPlayArtist = onPlayArtist,
        )
        mostPlayedSection(sections.mostPlayedTracks, onMostPlayedTap)
        lastPlayedSection(sections.lastPlayedArtists, onArtistClick, onPlayArtist)
        item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
    }
}

/**
 * The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
 * rendering with a distinct title; hidden entirely when there's nothing
 * to show (offline with no cached mixes). Online with none generated yet
 * still shows a few placeholders so the building / seed-needed state is
 * visible, matching the pre-promotion carousel behavior.
 */
private fun LazyListScope.songsLikeSection(
    playlists: List<PlaylistRef>,
    status: SystemPlaylistsStatus,
    offline: Boolean,
    onPlaylistClick: (String) -> Unit,
    onPlayPool: (OfflinePoolKind) -> Unit,
    onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
    val rowItems = buildSongsLikeRow(playlists, status, offline)
    if (rowItems.isEmpty()) return
    item {
        PlaylistsRow(
            title = "Songs like…",
            rowItems = rowItems,
            offline = offline,
            onPlaylistClick = onPlaylistClick,
            onPlayPool = onPlayPool,
            onPlayPlaylist = onPlayPlaylist,
        )
    }
}

private fun LazyListScope.recentlyAddedSection(
    albums: List<HomeTile<AlbumRef>>,
    onAlbumClick: (String) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
) {
    if (albums.isEmpty()) {
        item {
            EmptySection(
                title = "Recently added",
                body = "Albums you add to the library will show up here first.",
            )
        }
        return
    }
    item {
        RecentlyAddedGrid(
            albums = albums,
            onAlbumClick = onAlbumClick,
            onPlayAlbum = onPlayAlbum,
        )
    }
}

/**
 * Multi-row horizontal grid for Recently Added, mirroring the
 * Most Played pattern: tiles laid out column-major across
 * [RECENTLY_ADDED_GRID_ROWS] rows inside one LazyHorizontalGrid so
 * every row scrolls together as one panel. Replaces the previous
 * stacked-LazyRows layout which scrolled each chunk independently.
 */
@Composable
private fun RecentlyAddedGrid(
    albums: List<HomeTile<AlbumRef>>,
    onAlbumClick: (String) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
) {
    Column(modifier = Modifier.fillMaxWidth()) {
        Text(
            text = "Recently added",
            style = MaterialTheme.typography.titleLarge,
            color = MaterialTheme.colorScheme.onBackground,
            modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
        )
        // Re-flatten column-major to match Web's "ranks across the
        // top row, then across the middle, then bottom" reading
        // order. LazyHorizontalGrid otherwise fills column-major
        // directly from a flat list and would scramble per-rank
        // expectations.
        val perRow = (albums.size + RECENTLY_ADDED_GRID_ROWS - 1) / RECENTLY_ADDED_GRID_ROWS
        val rows = albums.chunked(perRow.coerceAtLeast(1))
        val cols = rows.maxOfOrNull { it.size } ?: 0
        val ordered = buildList {
            for (c in 0 until cols) {
                for (r in 0 until RECENTLY_ADDED_GRID_ROWS) {
                    rows.getOrNull(r)?.getOrNull(c)?.let { add(it) }
                }
            }
        }
        LazyHorizontalGrid(
            rows = GridCells.Fixed(RECENTLY_ADDED_GRID_ROWS),
            contentPadding = PaddingValues(horizontal = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier
                .fillMaxWidth()
                .height(RECENTLY_ADDED_GRID_HEIGHT_DP.dp),
        ) {
            gridItems(items = ordered, key = { it.id }) { tile ->
                val album = tile.value
                if (album == null) {
                    SkeletonAlbumTile()
                } else {
                    AlbumCard(
                        album = album,
                        onClick = { onAlbumClick(album.id) },
                        onPlay = { onPlayAlbum(album.id) },
                    )
                }
            }
        }
    }
}

private fun LazyListScope.rediscoverSection(
    albums: List<HomeTile<AlbumRef>>,
    artists: List<HomeTile<ArtistRef>>,
    onAlbumClick: (String) -> Unit,
    onArtistClick: (String) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
    onPlayArtist: suspend (String) -> Unit,
) {
    item {
        if (albums.isNotEmpty() || artists.isNotEmpty()) {
            RediscoverBlock(
                albums = albums,
                artists = artists,
                onAlbumClick = onAlbumClick,
                onArtistClick = onArtistClick,
                onPlayAlbum = onPlayAlbum,
                onPlayArtist = onPlayArtist,
            )
        } else {
            EmptySection(
                title = "Rediscover",
                body = "No forgotten favourites yet. Like albums or artists to fill this in.",
            )
        }
    }
}

private fun LazyListScope.mostPlayedSection(
    tracks: List<HomeTile<TrackRef>>,
    onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
) {
    item {
        if (tracks.isNotEmpty()) {
            MostPlayedRow(tracks, onMostPlayedTap)
        } else {
            EmptySection(
                title = "Most played",
                body = "Play some music — your most-listened tracks will appear here.",
            )
        }
    }
}

private fun LazyListScope.lastPlayedSection(
    artists: List<HomeTile<ArtistRef>>,
    onArtistClick: (String) -> Unit,
    onPlayArtist: suspend (String) -> Unit,
) {
    item {
        if (artists.isNotEmpty()) {
            ArtistsRow("Last played", artists, onArtistClick, onPlayArtist)
        } else {
            EmptySection(
                title = "Last played",
                body = "Recently played artists will show up here.",
            )
        }
    }
}

@Composable
private fun EmptySection(title: String, body: String) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 12.dp),
    ) {
        Text(
            text = title,
            style = MaterialTheme.typography.titleLarge,
            color = MaterialTheme.colorScheme.onBackground,
        )
        Spacer(Modifier.height(4.dp))
        Text(
            text = body,
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

/**
 * Rediscover may have both an albums sub-row and an artists sub-row. When
 * both are present the artist row gets an empty title so the section
 * reads as a single visual block — matches the Flutter behavior.
 */
@Composable
private fun RediscoverBlock(
    albums: List<HomeTile<AlbumRef>>,
    artists: List<HomeTile<ArtistRef>>,
    onAlbumClick: (String) -> Unit,
    onArtistClick: (String) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
    onPlayArtist: suspend (String) -> Unit,
) {
    Column {
        if (albums.isNotEmpty()) {
            AlbumsRow("Rediscover", albums, onAlbumClick, onPlayAlbum)
        }
        if (artists.isNotEmpty()) {
            val title = if (albums.isEmpty()) "Rediscover" else ""
            ArtistsRow(title, artists, onArtistClick, onPlayArtist)
        }
    }
}

/**
 * "You might like" — in-library albums/artists predicted from the user's
 * taste that they don't actively spin (server section, daily-built, cold-
 * start gated). Sits directly under the system-playlists row. Same
 * albums-then-artists block shape as Rediscover; the artist sub-row drops
 * its title when an album sub-row precedes it so the two read as one section.
 */
private fun LazyListScope.youMightLikeSection(
    albums: List<HomeTile<AlbumRef>>,
    artists: List<HomeTile<ArtistRef>>,
    onAlbumClick: (String) -> Unit,
    onArtistClick: (String) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
    onPlayArtist: suspend (String) -> Unit,
) {
    item {
        if (albums.isNotEmpty() || artists.isNotEmpty()) {
            YouMightLikeBlock(
                albums = albums,
                artists = artists,
                onAlbumClick = onAlbumClick,
                onArtistClick = onArtistClick,
                onPlayAlbum = onPlayAlbum,
                onPlayArtist = onPlayArtist,
            )
        } else {
            EmptySection(
                title = "You might like",
                body = "We're still learning your taste — keep listening and " +
                    "picks you might like will show up here.",
            )
        }
    }
}

@Composable
private fun YouMightLikeBlock(
    albums: List<HomeTile<AlbumRef>>,
    artists: List<HomeTile<ArtistRef>>,
    onAlbumClick: (String) -> Unit,
    onArtistClick: (String) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
    onPlayArtist: suspend (String) -> Unit,
) {
    Column {
        if (albums.isNotEmpty()) {
            AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
        }
        if (artists.isNotEmpty()) {
            val title = if (albums.isEmpty()) "You might like" else ""
            ArtistsRow(title, artists, onArtistClick, onPlayArtist)
        }
    }
}

@Composable
private fun AlbumsRow(
    title: String,
    albums: List<HomeTile<AlbumRef>>,
    onAlbumClick: (String) -> Unit,
    onPlayAlbum: suspend (String) -> Unit,
) {
    HorizontalScrollRow(title = title) {
        items(items = albums, key = { it.id }) { tile ->
            val album = tile.value
            if (album == null) {
                SkeletonAlbumTile()
            } else {
                AlbumCard(
                    album = album,
                    onClick = { onAlbumClick(album.id) },
                    onPlay = { onPlayAlbum(album.id) },
                )
            }
        }
    }
}

@Composable
private fun PlaylistsRow(
    title: String,
    rowItems: List<PlaylistRowItem>,
    offline: Boolean,
    onPlaylistClick: (String) -> Unit,
    onPlayPool: (OfflinePoolKind) -> Unit,
    onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
    HorizontalScrollRow(title = title) {
        itemsIndexed(items = rowItems) { _, item ->
            when (item) {
                is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
                    label = item.kind.label,
                    icon = iconForPool(item.kind),
                    onClick = { onPlayPool(item.kind) },
                )
                is PlaylistRowItem.Real -> {
                    // Greyed offline when the tile needs the live server or
                    // isn't fully cached — dimmed but still tappable into the
                    // detail to shuffle whatever subset is cached.
                    val greyed = offline && item.playlist.unavailableOffline
                    PlaylistCard(
                        playlist = item.playlist,
                        onClick = { onPlaylistClick(item.playlist.id) },
                        onPlay = { onPlayPlaylist(item.playlist) },
                        playEnabled = item.playlist.trackCount > 0 && !greyed,
                        greyed = greyed,
                    )
                }
                is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
                    label = item.label,
                    variant = item.variant,
                )
            }
        }
    }
}

private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
    OfflinePoolKind.RECENTLY_PLAYED -> Lucide.History
    OfflinePoolKind.LIKED -> Lucide.Heart
}

/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
internal sealed interface PlaylistRowItem {
    data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
    data class Real(val playlist: PlaylistRef) : PlaylistRowItem
    data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
}

/** The two offline cache pools surfaced on Home when offline. */
enum class OfflinePoolKind(val label: String) {
    RECENTLY_PLAYED("Recently played"),
    LIKED("Liked"),
}

/**
 * Builds the Home Playlists row.
 *
 * Online: For You + Discover fixed slots (real card when generated,
 * placeholder otherwise), then the secondary system kinds (deep cuts /
 * rediscover / new for you / on this day / first listens) when they exist —
 * no placeholders for these since they're conditional on library shape — then
 * user-owned playlists. Songs-like has its own dedicated row (#1491) via
 * [buildSongsLikeRow] and no longer appears in this carousel.
 *
 * Offline: the two cache-backed pools (Recently played, Liked) lead, then the
 * same real playlists in curated order but stably partitioned fully-cached
 * first / greyed after, and the "building/pending" placeholders are dropped
 * (they need the server to generate, so they're meaningless offline).
 *
 * Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
 * `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
 * surfaces the secondary kinds on Home. Operator authorized the
 * divergence on 2026-06-01; web UI catch-up tracked as task #53.
 */
internal fun buildPlaylistsRow(
    owned: List<PlaylistRef>,
    status: SystemPlaylistsStatus,
    offline: Boolean,
): List<PlaylistRowItem> {
    if (!offline) return buildOnlineRow(owned, status)
    val out = mutableListOf<PlaylistRowItem>(
        PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
        PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
    )
    val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
    (available + greyed).forEach { out += PlaylistRowItem.Real(it) }
    return out
}

/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
private fun buildOnlineRow(
    owned: List<PlaylistRef>,
    status: SystemPlaylistsStatus,
): List<PlaylistRowItem> {
    val out = mutableListOf<PlaylistRowItem>()
    out += owned.firstOrNull { it.systemVariant == "for_you" }
        ?.let { PlaylistRowItem.Real(it) }
        ?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
    out += owned.firstOrNull { it.systemVariant == "discover" }
        ?.let { PlaylistRowItem.Real(it) }
        ?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
    // Songs-like is no longer here — it has its own dedicated row (#1491).
    for (variant in SECONDARY_SYSTEM_VARIANTS) {
        owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
    }
    owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
    return out
}

/**
 * Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
 * mixes the server generated, no longer capped to the 3 carousel slots.
 *
 * Online: every generated mix as a real card; when none exist yet, a few
 * placeholders so the building / seed-needed state stays visible.
 * Offline: the cached mixes only (fully-cached first, greyed after), and
 * an empty list — hiding the whole section — when nothing is cached.
 */
internal fun buildSongsLikeRow(
    owned: List<PlaylistRef>,
    status: SystemPlaylistsStatus,
    offline: Boolean,
): List<PlaylistRowItem> {
    val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
    return when {
        offline -> {
            val (available, greyed) = mixes.partition { !it.unavailableOffline }
            (available + greyed).map { PlaylistRowItem.Real(it) }
        }
        mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
        else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
            PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
        }
    }
}

/**
 * Curated real-playlist order (system primaries, then secondary, then user).
 * Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
 * only differs by dropping placeholders + partitioning available-first.
 * Songs-like is excluded here too — it renders in its own row via
 * [buildSongsLikeRow] in both online and offline modes (#1491).
 */
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
    val out = mutableListOf<PlaylistRef>()
    owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
    owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
    for (variant in SECONDARY_SYSTEM_VARIANTS) {
        owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
    }
    owned.filter { it.systemVariant == null }.forEach { out += it }
    return out
}

private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
    s.inFlight -> "building"
    s.lastError != null -> "failed"
    slot == "songs-like" && s.lastRunAt != null -> "seed-needed"
    else -> "pending"
}

// How many "Songs like…" placeholder cards the dedicated row shows while
// the mixes haven't generated yet (building / seed-needed). Real mixes,
// once generated, are shown in full and no longer capped by this (#1491).
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3

/**
 * The 5 system playlist kinds the server generates that aren't pinned
 * to Home's primary slots. Order matches `systemPlaylistRegistry` in
 * `internal/playlists/system.go`.
 */
private val SECONDARY_SYSTEM_VARIANTS = listOf(
    "deep_cuts",
    "rediscover",
    "new_for_you",
    "on_this_day",
    "first_listens",
)

@Composable
private fun ArtistsRow(
    title: String,
    artists: List<HomeTile<ArtistRef>>,
    onArtistClick: (String) -> Unit,
    onPlayArtist: suspend (String) -> Unit,
) {
    HorizontalScrollRow(title = title) {
        items(items = artists, key = { it.id }) { tile ->
            val artist = tile.value
            if (artist == null) {
                SkeletonArtistTile()
            } else {
                ArtistCard(
                    artist = artist,
                    onClick = { onArtistClick(artist.id) },
                    onPlay = { onPlayArtist(artist.id) },
                )
            }
        }
    }
}

/**
 * Most-played section. Renders compact track tiles (cover + title + artist).
 * Tapping a tile replaces the queue with the entire section starting
 * at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour
 * where Home sections are themselves playable units.
 */
@Composable
private fun MostPlayedRow(
    tracks: List<HomeTile<TrackRef>>,
    onTap: (List<TrackRef>, Int) -> Unit,
) {
    Column(modifier = Modifier.fillMaxWidth()) {
        Text(
            text = "Most played",
            style = MaterialTheme.typography.titleLarge,
            color = MaterialTheme.colorScheme.onBackground,
            modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
        )
        // Match web's chunked-rows-in-shared-scroller layout
        // (web/src/routes/+page.svelte:196): tracks are dealt into
        // MOST_PLAYED_ROWS rows of equal width, all stacked inside one
        // horizontal scroller. Web is row-major (ranks 0..24 across the
        // top row, 25..49 across the middle, 50..74 across the bottom);
        // LazyHorizontalGrid fills column-major, so we re-flatten the
        // chunked input column-by-column to match the web order.
        val perRow = (tracks.size + MOST_PLAYED_ROWS - 1) / MOST_PLAYED_ROWS
        val rows = tracks.chunked(perRow.coerceAtLeast(1))
        val cols = rows.maxOfOrNull { it.size } ?: 0
        val ordered = buildList {
            for (c in 0 until cols) {
                for (r in 0 until MOST_PLAYED_ROWS) {
                    rows.getOrNull(r)?.getOrNull(c)?.let { add(it) }
                }
            }
        }
        LazyHorizontalGrid(
            rows = GridCells.Fixed(MOST_PLAYED_ROWS),
            contentPadding = PaddingValues(horizontal = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier
                .fillMaxWidth()
                .height(MOST_PLAYED_GRID_HEIGHT_DP.dp),
        ) {
            gridItems(items = ordered, key = { it.id }) { tile ->
                val track = tile.value
                if (track == null) {
                    SkeletonCompactTrackTile()
                } else {
                    CompactTrackTile(
                        track = track,
                        onClick = {
                            val playable = tracks.mapNotNull { it.value }
                            onTap(playable, playable.indexOfFirst { it.id == track.id })
                        },
                    )
                }
            }
        }
    }
}

private const val MOST_PLAYED_ROWS = 3
private const val MOST_PLAYED_TILE_WIDTH_DP = 176
private const val MOST_PLAYED_TILE_HEIGHT_DP = 56
private const val MOST_PLAYED_COVER_DP = 48

// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
// rounded up. Mirrors Flutter (`CompactTrackCard` in
// flutter_client/lib/library/widgets/compact_track_card.dart) which
// uses a horizontal-row card pattern - much denser than the square
// per-track tiles that web uses (operator request 2026-06-01: "in the
// flutter iteration the tiles were different and smaller so more of
// them could fit in a smaller space").
private const val MOST_PLAYED_GRID_HEIGHT_DP = 200

@Composable
private fun SkeletonCompactTrackTile() {
    Row(
        modifier = Modifier
            .width(MOST_PLAYED_TILE_WIDTH_DP.dp)
            .height(MOST_PLAYED_TILE_HEIGHT_DP.dp)
            .padding(horizontal = 8.dp, vertical = 4.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            modifier = Modifier
                .size(MOST_PLAYED_COVER_DP.dp)
                .clip(RoundedCornerShape(4.dp))
                .background(MaterialTheme.colorScheme.surfaceVariant),
        )
    }
}

@Composable
private fun CompactTrackTile(
    track: TrackRef,
    onClick: () -> Unit,
) {
    Row(
        modifier = Modifier
            .width(MOST_PLAYED_TILE_WIDTH_DP.dp)
            .height(MOST_PLAYED_TILE_HEIGHT_DP.dp)
            .clickable(onClick = onClick)
            .padding(horizontal = 8.dp, vertical = 4.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            modifier = Modifier
                .size(MOST_PLAYED_COVER_DP.dp)
                .clip(RoundedCornerShape(4.dp))
                .background(MaterialTheme.colorScheme.surfaceVariant),
            contentAlignment = Alignment.Center,
        ) {
            if (track.coverUrl.isEmpty()) {
                Icon(
                    imageVector = Lucide.Music,
                    contentDescription = null,
                    tint = MaterialTheme.colorScheme.onSurfaceVariant,
                    modifier = Modifier.size(20.dp),
                )
            } else {
                AsyncImage(
                    model = track.coverUrl,
                    contentDescription = track.title,
                    modifier = Modifier.fillMaxSize(),
                    contentScale = ContentScale.Crop,
                )
            }
        }
        Spacer(Modifier.width(8.dp))
        Column(
            modifier = Modifier
                .weight(1f)
                .fillMaxHeight(),
            verticalArrangement = Arrangement.Center,
        ) {
            Text(
                text = track.title,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurface,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            Text(
                text = track.artistName.ifEmpty { " " },
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

