package com.keylightpiano.pianotimeline.ui

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.DeepLinkParser
import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.NetworkMonitor
import com.keylightpiano.pianotimeline.data.SettingsStore
import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.MediaCache
import com.keylightpiano.pianotimeline.player.PlaybackState
import com.keylightpiano.pianotimeline.player.PlayerManager
import kotlinx.coroutines.Dispatchers
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.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

/**
 * Drives the main recording list screen.
 *
 * Architecture: three independent state streams (search query, sort mode, artist filters)
 * are combined with the Room-backed recording list into one final filtered+sorted list.
 * Compose observes the result and recomposes when anything changes.
 */
class RecordingListViewModel(app: Application) : AndroidViewModel(app) {

    private val repo = MusicRepository(app)
    private val syncManager = SyncManager(app)
    private val settings = SettingsStore(app)
    private val playerManager = PlayerManager(
        app,
        viewModelScope,
        onTrackEnded = { advanceToNext() },
        onCacheMayHaveChanged = { bumpCacheGeneration() }
    )

    /** Playback state the UI observes (which card is playing, position, etc). */
    val playbackState: StateFlow<PlaybackState> = playerManager.state

    // -- Connectivity --

    private val networkMonitor = NetworkMonitor(app)

    /**
     * True while the device has usable internet.
     *
     * Debounced by 800ms: when a connection is (re)established, Android fires a
     * burst of capability callbacks and can momentarily report conflicting
     * states. Without debouncing that surfaced as the whole list graying out for
     * a split second on reconnect. Debounce waits for the signal to settle before
     * emitting, so brief blips never reach the UI.
     */
    val isOnline: StateFlow<Boolean> =
        networkMonitor.observe()
            .debounce(800L)
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), networkMonitor.isOnlineNow())

    // -- Continuous play: repeat + shuffle --

    private val _repeatMode = MutableStateFlow(RepeatMode.OFF)
    val repeatMode: StateFlow<RepeatMode> = _repeatMode.asStateFlow()

    private val _shuffle = MutableStateFlow(false)
    val shuffle: StateFlow<Boolean> = _shuffle.asStateFlow()

    /**
     * When shuffle is on, this holds a shuffled permutation of recording IDs
     * representing the play order for the current pass. Rebuilt when shuffle is
     * toggled on, when a track is manually started, or when a shuffled pass
     * completes under Repeat All.
     */
    private var shuffleOrder: List<String> = emptyList()

    // -- Cache freshness signal --

    /**
     * Bumped whenever something changes the on-disk cache in a way the recording
     * list needs to reflect: "clear cache" (via clearCache) and a track finishing
     * playback (via the player's onCacheMayHaveChanged, since a fully-played track
     * is now fully cached). The `recordings` flow folds this in, so incrementing
     * it forces `isCached` to be recomputed and the "downloaded" glyphs to update
     * immediately -- no app restart.
     */
    private val _cacheGeneration = MutableStateFlow(0)

    /** Nudge the recordings flow to re-check on-disk cache state. */
    private fun bumpCacheGeneration() {
        _cacheGeneration.value += 1
    }

    // -- Settings state --

    /**
     * "Notify me about new recordings" toggle. Persisted, but currently a no-op
     * beyond storage -- notification delivery isn't wired up yet. The Settings UI
     * observes this to render the switch state.
     */
    private val _notificationsEnabled = MutableStateFlow(false)
    val notificationsEnabled: StateFlow<Boolean> = _notificationsEnabled.asStateFlow()

    /**
     * Cached-media size in bytes, shown on the Settings screen. Null = "not yet
     * computed" (Settings hasn't asked for it, or a refresh is in flight). The
     * Settings screen calls refreshCacheSize() when it opens.
     */
    private val _cacheSizeBytes = MutableStateFlow<Long?>(null)
    val cacheSizeBytes: StateFlow<Long?> = _cacheSizeBytes.asStateFlow()

    // -- Deep-link signals the UI observes --

    /**
     * Set to a recording id when an incoming link should make the list scroll
     * to that entry. The screen observes this, scrolls, then calls
     * consumeScrollTarget() so it fires only once.
     */
    private val _scrollToId = MutableStateFlow<String?>(null)
    val scrollToId: StateFlow<String?> = _scrollToId.asStateFlow()

    fun consumeScrollTarget() { _scrollToId.value = null }

    /**
     * Set to a recording when a .mid link should open the falling-notes view.
     * Null until requested. The screen observes this and navigates, then calls
     * consumeVisualizationRequest(). (Visualization screen comes later; for now
     * this lets us wire the routing and verify it fires.)
     */
    private val _openVisualizationFor = MutableStateFlow<Recording?>(null)
    val openVisualizationFor: StateFlow<Recording?> = _openVisualizationFor.asStateFlow()

    fun consumeVisualizationRequest() { _openVisualizationFor.value = null }

    // -- Share URLs (used by the long-press menu) --

    fun audioShareUrl(recording: Recording): String = repo.audioUrl(recording)

    fun midiShareUrl(recording: Recording): String? = repo.midiUrl(recording)

    // -- Sync state --

    private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing)
    val syncState: StateFlow<SyncState> = _syncState.asStateFlow()

    /** True while a pull-to-refresh is in flight (keeps the list visible, spinner on top). */
    private val _isRefreshing = MutableStateFlow(false)
    val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()

    // -- User-controlled filters --

    private val _searchQuery = MutableStateFlow("")
    val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()

    private val _sortMode = MutableStateFlow(SortMode.DATE_DESC)
    val sortMode: StateFlow<SortMode> = _sortMode.asStateFlow()

    /**
     * Set of HIDDEN grouping-artist names. Empty = everything visible.
     * We track hidden rather than visible so the default (empty set = show all)
     * works without knowing the full artist list up front.
     */
    private val _hiddenArtists = MutableStateFlow<Set<String>>(emptySet())
    val hiddenArtists: StateFlow<Set<String>> = _hiddenArtists.asStateFlow()

    /**
     * Set of HIDDEN years. Empty = every year visible. Same pattern as artists.
     */
    private val _hiddenYears = MutableStateFlow<Set<Int>>(emptySet())
    val hiddenYears: StateFlow<Set<Int>> = _hiddenYears.asStateFlow()

    // -- Derived: all unique grouping artists, for the filter UI --

    /** All recordings straight from Room (unfiltered). */
    private val allRecordings = repo.observeRecordings()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    /**
     * Sorted list of unique grouping artists derived from the full recording list.
     * Updates automatically if recordings change.
     */
    val availableArtists: StateFlow<List<String>> = allRecordings
        .mapToState(viewModelScope) { recordings ->
            recordings.map { it.groupingArtist }
                .distinct()
                .sorted()
        }

    /**
     * Distinct years present in the library, newest first (matches the default sort).
     */
    val availableYears: StateFlow<List<Int>> = allRecordings
        .mapToState(viewModelScope) { recordings ->
            recordings.map { it.date.year }
                .distinct()
                .sortedDescending()
        }

    // -- The final filtered + sorted list the UI renders --

    private val filteredRecordings: kotlinx.coroutines.flow.Flow<List<Recording>> = combine(
        allRecordings,
        _searchQuery,
        _sortMode,
        _hiddenArtists,
        _hiddenYears
    ) { all, query, sort, hidden, hiddenYears ->
        val filteredSorted = all.filterByArtist(hidden)
            .filterByYear(hiddenYears)
            .filterBySearch(query)
            .sortedWith(sort.comparator())
        // Split into favorites and the rest. Favorites sit at the top, ordered by
        // WHEN they were favorited (oldest first -> newest favorite at the bottom
        // of the group), which reads as insertion order. Non-favorites keep the
        // user's chosen sort.
        val (favorites, others) = filteredSorted.partition { it.isFavorite }
        favorites.sortedBy { it.favoritedAt } + others
    }

    /**
     * The list the UI renders. On top of the filtered/sorted list we stamp each
     * recording's live `isCached` flag -- derived from the real ExoPlayer disk
     * cache (MediaCache), not the unused Room column. Recomputed whenever
     * connectivity flips (isOnline) OR the cache generation is bumped (e.g. after
     * "clear cache"), so "playable offline" stays fresh. The map runs on IO
     * because it touches cache metadata on disk.
     */
    val recordings: StateFlow<List<Recording>> =
        combine(filteredRecordings, isOnline, _cacheGeneration) { list, _, _ ->
            list.map { rec ->
                val cached = MediaCache.isFullyCached(app, repo.audioUrl(rec))
                if (cached == rec.isCached) rec else rec.copy(isCached = cached)
            }
        }
            .flowOn(Dispatchers.IO)
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    // -- Init: run sync on creation --

    init {
        // Restore persisted playback preferences (shuffle / repeat) before sync.
        viewModelScope.launch {
            _shuffle.value = settings.getShuffle()
            _repeatMode.value = runCatching {
                RepeatMode.valueOf(settings.getRepeatModeName())
            }.getOrDefault(RepeatMode.OFF)
            _notificationsEnabled.value = settings.getNotificationsEnabled()
        }

        // Restore the last-played track (PAUSED, Spotify-style) once the list is
        // available so we can resolve the id -> URL. Fires only once.
        viewModelScope.launch {
            val savedId = settings.getLastTrackId() ?: return@launch
            val savedPos = settings.getLastPositionMs()
            // Wait for the first non-empty list, then match the saved id against it.
            val list = recordings.first { it.isNotEmpty() }
            val match = list.firstOrNull { it.id == savedId }
            if (match != null) {
                playerManager.preparePaused(match.id, repo.audioUrl(match), savedPos)
            } else {
                // Track no longer exists (removed on the server, or storage cleared)
                // -- drop the stale pointer so we don't keep trying.
                settings.clearLastPlayed()
            }
        }

        // Throttled persistence of the current track + offset: at most once per
        // second, and only while a track is loaded. Saving live (every 200ms
        // position tick) would hammer DataStore; 1s is plenty for resume.
        viewModelScope.launch {
            var lastSaveAt = 0L
            playbackState.collect { s ->
                val id = s.nowPlayingId ?: return@collect
                val now = System.currentTimeMillis()
                if (now - lastSaveAt >= 1000L) {
                    lastSaveAt = now
                    settings.setLastPlayed(id, s.positionMs)
                }
            }
        }

        // When recordings become available, flush any deep link that arrived
        // before the list was ready.
        viewModelScope.launch {
            recordings.collect {
                if (pendingLink != null) tryResolvePendingLink()
            }
        }

        viewModelScope.launch {
            // No connection -> don't even attempt a network sync. Show whatever is
            // cached in Room if we have it; otherwise surface an offline error.
            if (!isOnline.value) {
                _syncState.value =
                    if (syncManager.hasCachedData()) SyncState.Ready
                    else SyncState.Error("You're offline and no recordings are cached yet.")
                return@launch
            }

            _syncState.value = SyncState.Syncing
            val result = syncManager.sync()
            _syncState.value = when (result) {
                is SyncManager.SyncResult.UpToDate -> SyncState.Ready
                is SyncManager.SyncResult.Updated -> SyncState.Ready
                is SyncManager.SyncResult.Failed -> {
                    // If we have cached data, still show it despite sync failure
                    if (syncManager.hasCachedData()) SyncState.Ready
                    else SyncState.Error(result.message)
                }
            }
        }
    }

    // -- Public actions the UI calls --

    fun setSearchQuery(query: String) {
        _searchQuery.value = query
    }

    fun setSortMode(mode: SortMode) {
        _sortMode.value = mode
    }

    fun toggleArtist(groupingArtist: String) {
        _hiddenArtists.value = _hiddenArtists.value.let { current ->
            if (groupingArtist in current) current - groupingArtist
            else current + groupingArtist
        }
    }

    fun showAllArtists() {
        _hiddenArtists.value = emptySet()
    }

    fun hideAllArtists() {
        _hiddenArtists.value = availableArtists.value.toSet()
    }

    fun toggleYear(year: Int) {
        _hiddenYears.value = _hiddenYears.value.let { current ->
            if (year in current) current - year else current + year
        }
    }

    fun showAllYears() {
        _hiddenYears.value = emptySet()
    }

    fun hideAllYears() {
        _hiddenYears.value = availableYears.value.toSet()
    }

    fun toggleFavorite(recording: Recording) {
        viewModelScope.launch {
            repo.setFavorite(recording.id, !recording.isFavorite)
        }
    }

    /** Manually re-apply the "new" badge (from the long-press menu). */
    fun markAsNew(recording: Recording) {
        viewModelScope.launch {
            repo.markAsNew(recording.id)
        }
    }

    // -- Settings actions --

    /**
     * Toggle the "notify on new recordings" preference. Persists the flag only --
     * no delivery mechanism is wired up yet. When notifications are actually
     * implemented, the permission-request flow (POST_NOTIFICATIONS on Android 13+)
     * will be triggered from the UI layer, not here, since it needs an Activity.
     */
    fun setNotificationsEnabled(enabled: Boolean) {
        _notificationsEnabled.value = enabled
        viewModelScope.launch { settings.setNotificationsEnabled(enabled) }
    }

    /** Recompute the cached-media size shown on the Settings screen. */
    fun refreshCacheSize() {
        viewModelScope.launch {
            _cacheSizeBytes.value = repo.cacheSizeBytes(getApplication())
        }
    }

    /**
     * Clear all cached recordings from disk, then refresh the displayed size and
     * bump the cache generation so the list drops its "downloaded" glyphs
     * immediately. Playback is stopped first because SimpleCache can't be cleared
     * while a player is actively reading from it.
     */
    fun clearCache() {
        viewModelScope.launch {
            // Stop any active playback so the cache isn't being read during clear.
            playerManager.stop()
            settings.clearLastPlayed()
            repo.clearAllCache(getApplication())
            _cacheSizeBytes.value = repo.cacheSizeBytes(getApplication())
            // Force the recordings flow to recompute isCached now (not on restart).
            bumpCacheGeneration()
        }
    }

    /** Clear the "new" badge from every recording (Settings action). */
    fun clearAllNewBadges() {
        viewModelScope.launch {
            repo.clearAllNew()
        }
    }

    /**
     * Called when a recording's play button is pressed. Clears its "new" badge,
     * then plays (or toggles pause/resume if it's already the active track).
     */
    fun onPlay(recording: Recording) {
        if (recording.isNew) {
            viewModelScope.launch { repo.clearNew(recording.id) }
        }
        // A manual play (re)bases the shuffle order around the chosen track.
        if (_shuffle.value) rebuildShuffleOrder(startId = recording.id)
        val url = repo.audioUrl(recording)
        playerManager.playOrToggle(recording.id, url)
    }

    /** Mini-player controls. */
    fun togglePlayPause() {
        val s = playbackState.value
        if (s.isPlaying) playerManager.pause() else playerManager.resume()
    }

    fun seekTo(positionMs: Long) = playerManager.seekTo(positionMs)

    fun stopPlayback() {
        playerManager.stop()
        viewModelScope.launch { settings.clearLastPlayed() }
    }

    // -- Deep-link handling --

    /** A link that arrived before the recording list was ready, retried later. */
    private var pendingLink: DeepLinkParser.ParsedLink? = null

    /**
     * Handle an incoming app-link URL. If the matching recording is already
     * loaded, act immediately; otherwise stash it and resolve once data arrives.
     */
    fun handleDeepLink(uri: android.net.Uri) {
        val parsed = DeepLinkParser.parse(uri) ?: return
        if (!resolveLink(parsed)) {
            pendingLink = parsed
        }
    }

    /**
     * Try to act on a parsed link against the current list.
     * Returns true if handled, false if the target isn't loaded yet.
     */
    private fun resolveLink(parsed: DeepLinkParser.ParsedLink): Boolean {
        val list = recordings.value
        if (list.isEmpty()) return false

        val match = when (parsed.type) {
            DeepLinkParser.LinkType.AUDIO ->
                list.firstOrNull { it.fileName == parsed.fileName }
            DeepLinkParser.LinkType.MIDI ->
                list.firstOrNull { it.midiFileName == parsed.fileName }
        } ?: return false  // no match in current list (maybe filtered out / removed)

        // Always scroll the list to the entry.
        _scrollToId.value = match.id

        when (parsed.type) {
            DeepLinkParser.LinkType.AUDIO -> {
                if (_shuffle.value) rebuildShuffleOrder(startId = match.id)
                playerManager.playOrToggle(match.id, repo.audioUrl(match))
            }
            DeepLinkParser.LinkType.MIDI -> {
                _openVisualizationFor.value = match
            }
        }
        return true
    }

    /** Called internally once recordings load, to flush any pending link. */
    private fun tryResolvePendingLink() {
        val p = pendingLink ?: return
        if (resolveLink(p)) pendingLink = null
    }

    /**
     * Manual "next" button -- behaves like a natural track-end advance,
     * honoring shuffle and repeat-all, but a manual skip should always move on
     * even under Repeat One (you don't want the button to just replay).
     */
    fun skipNext() {
        val current = playbackState.value.nowPlayingId ?: return
        val visible = recordings.value
        if (visible.isEmpty()) return

        val next = if (_shuffle.value) nextShuffled(current, visible)
                   else nextSequential(current, visible)
        // If we're at the end with Repeat Off, wrap anyway on a manual skip.
        val target = next ?: visible.firstOrNull()
        target?.let { playerManager.playOrToggle(it.id, repo.audioUrl(it)) }
    }

    /**
     * Manual "previous" button. Standard player behavior: if we're more than
     * ~3 seconds into the track, restart it; otherwise jump to the previous one.
     */
    fun skipPrevious() {
        val current = playbackState.value.nowPlayingId ?: return
        if (playbackState.value.positionMs > 3000L) {
            playerManager.replayCurrent()
            return
        }
        val visible = recordings.value
        if (visible.isEmpty()) return

        val prev = if (_shuffle.value) previousShuffled(current, visible)
                   else previousSequential(current, visible)
        val target = prev ?: visible.lastOrNull()
        target?.let { playerManager.playOrToggle(it.id, repo.audioUrl(it)) }
    }

    private fun previousSequential(currentId: String, visible: List<Recording>): Recording? {
        val idx = visible.indexOfFirst { it.id == currentId }
        if (idx == -1) return visible.firstOrNull()
        val prevIdx = idx - 1
        return when {
            prevIdx >= 0 -> visible[prevIdx]
            _repeatMode.value == RepeatMode.ALL -> visible.lastOrNull()  // wrap to end
            else -> null
        }
    }

    private fun previousShuffled(currentId: String, visible: List<Recording>): Recording? {
        val visibleIds = visible.map { it.id }.toSet()
        shuffleOrder = shuffleOrder.filter { it in visibleIds }
        if (shuffleOrder.isEmpty()) rebuildShuffleOrder(startId = currentId)

        val idx = shuffleOrder.indexOf(currentId)
        val prevId = when {
            idx > 0 -> shuffleOrder[idx - 1]
            _repeatMode.value == RepeatMode.ALL -> shuffleOrder.lastOrNull()
            else -> null
        }
        return prevId?.let { id -> visible.firstOrNull { it.id == id } }
    }

    // -- Repeat / shuffle controls --

    fun cycleRepeatMode() {
        val next = when (_repeatMode.value) {
            RepeatMode.OFF -> RepeatMode.ALL
            RepeatMode.ALL -> RepeatMode.ONE
            RepeatMode.ONE -> RepeatMode.OFF
        }
        _repeatMode.value = next
        viewModelScope.launch { settings.setRepeatModeName(next.name) }
    }

    fun toggleShuffle() {
        val newValue = !_shuffle.value
        _shuffle.value = newValue
        viewModelScope.launch { settings.setShuffle(newValue) }
        if (newValue) {
            // Seed the shuffle around whatever's currently playing (if anything).
            rebuildShuffleOrder(startId = playbackState.value.nowPlayingId)
        }
    }

    /**
     * Build a fresh shuffled permutation of the currently-visible recordings.
     * If startId is given, that track is placed first so the current song isn't
     * interrupted and shuffle continues from it.
     */
    private fun rebuildShuffleOrder(startId: String?) {
        val ids = recordings.value.map { it.id }.toMutableList()
        ids.shuffle()
        if (startId != null) {
            ids.remove(startId)
            ids.add(0, startId)
        }
        shuffleOrder = ids
    }

    /**
     * Advance to the next track when one finishes naturally.
     * Honors repeat-one (replay), shuffle (next in permutation), and
     * repeat-all (wrap / reshuffle). Repeat-off stops at the end.
     */
    private fun advanceToNext() {
        val current = playbackState.value.nowPlayingId ?: return

        // Repeat One: just replay the same track.
        if (_repeatMode.value == RepeatMode.ONE) {
            playerManager.replayCurrent()
            return
        }

        val visible = recordings.value
        if (visible.isEmpty()) {
            playerManager.stop()
            return
        }

        val nextRecording: Recording? = if (_shuffle.value) {
            nextShuffled(current, visible)
        } else {
            nextSequential(current, visible)
        }

        if (nextRecording != null) {
            val url = repo.audioUrl(nextRecording)
            playerManager.playOrToggle(nextRecording.id, url)
        } else {
            // End of queue with Repeat Off -> stop.
            playerManager.stop()
        }
    }

    private fun nextSequential(currentId: String, visible: List<Recording>): Recording? {
        val idx = visible.indexOfFirst { it.id == currentId }
        if (idx == -1) return visible.firstOrNull()  // current fell out of the list
        val nextIdx = idx + 1
        return when {
            nextIdx < visible.size -> visible[nextIdx]
            _repeatMode.value == RepeatMode.ALL -> visible.firstOrNull()  // wrap
            else -> null  // end, repeat off
        }
    }

    private fun nextShuffled(currentId: String, visible: List<Recording>): Recording? {
        // Keep the shuffle order in sync with what's actually visible.
        val visibleIds = visible.map { it.id }.toSet()
        shuffleOrder = shuffleOrder.filter { it in visibleIds }
        if (shuffleOrder.isEmpty()) rebuildShuffleOrder(startId = currentId)

        val idx = shuffleOrder.indexOf(currentId)
        val nextId = when {
            idx != -1 && idx + 1 < shuffleOrder.size -> shuffleOrder[idx + 1]
            _repeatMode.value == RepeatMode.ALL -> {
                // Completed a pass -- reshuffle for the next one.
                rebuildShuffleOrder(startId = null)
                shuffleOrder.firstOrNull()
            }
            else -> null
        }
        return nextId?.let { id -> visible.firstOrNull { it.id == id } }
    }

    override fun onCleared() {
        super.onCleared()
        playerManager.release()
    }

    /**
     * Pull-to-refresh: re-fetch list.json and re-check revision, WITHOUT blanking
     * the list to the full-screen syncing spinner. The list stays visible; only the
     * small pull indicator shows. A failed refresh leaves the existing list intact.
     */
    fun refresh() {
        if (_isRefreshing.value) return  // ignore overlapping pulls
        // Offline -> there's nothing to fetch. Bail immediately so the pull
        // indicator doesn't spin against a network call that can only fail.
        if (!isOnline.value) return
        _isRefreshing.value = true
        viewModelScope.launch {
            try {
                syncManager.sync()
            } finally {
                _isRefreshing.value = false
            }
        }
    }

    fun retry() {
        _syncState.value = SyncState.Syncing
        viewModelScope.launch {
            if (!isOnline.value) {
                _syncState.value =
                    if (syncManager.hasCachedData()) SyncState.Ready
                    else SyncState.Error("You're offline and no recordings are cached yet.")
                return@launch
            }
            val result = syncManager.sync()
            _syncState.value = when (result) {
                is SyncManager.SyncResult.UpToDate -> SyncState.Ready
                is SyncManager.SyncResult.Updated -> SyncState.Ready
                is SyncManager.SyncResult.Failed -> SyncState.Error(result.message)
            }
        }
    }
}

// -- State types --

sealed interface SyncState {
    data object Syncing : SyncState
    data object Ready : SyncState
    data class Error(val message: String) : SyncState
}

/** Three-state repeat, like a normal audio player. */
enum class RepeatMode { OFF, ALL, ONE }

// -- Private helpers --

/**
 * Case-insensitive, token-based search.
 * Splits the query into words; a recording matches if EVERY word appears
 * somewhere in its artist, title, or extra-info. This gives a fuzzy feel
 * ("chopin nocturne" matches "Chopin - Nocturne Op. 9...") without needing
 * a real fuzzy library.
 */
private fun List<Recording>.filterBySearch(query: String): List<Recording> {
    if (query.isBlank()) return this
    val tokens = query.lowercase().split(" ").filter { it.isNotBlank() }
    return filter { recording ->
        val haystack = "${recording.artist} ${recording.title} ${recording.extraInfo}".lowercase()
        tokens.all { token -> haystack.contains(token) }
    }
}

private fun List<Recording>.filterByArtist(hidden: Set<String>): List<Recording> {
    if (hidden.isEmpty()) return this
    return filter { it.groupingArtist !in hidden }
}

private fun List<Recording>.filterByYear(hidden: Set<Int>): List<Recording> {
    if (hidden.isEmpty()) return this
    return filter { it.date.year !in hidden }
}

private fun SortMode.comparator(): Comparator<Recording> = when (this) {
    SortMode.DATE_DESC -> compareByDescending<Recording> { it.date }
        .thenByDescending { it.performanceNumber }
    SortMode.DATE_ASC -> compareBy<Recording> { it.date }
        // Oldest-first is the only mode that sub-orders performances ascending.
        .thenBy { it.performanceNumber }
    SortMode.ARTIST_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
        .thenByDescending { it.date }
        .thenByDescending { it.performanceNumber }
    SortMode.ARTIST_ZA -> compareByDescending<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.artist }
        .thenByDescending { it.date }
        .thenByDescending { it.performanceNumber }
    SortMode.TITLE_AZ -> compareBy<Recording, String>(String.CASE_INSENSITIVE_ORDER) { it.title }
        .thenByDescending { it.date }
        .thenByDescending { it.performanceNumber }
    SortMode.DURATION_DESC -> compareByDescending<Recording> { it.durationSeconds }
        .thenByDescending { it.performanceNumber }
    SortMode.DURATION_ASC -> compareBy<Recording> { it.durationSeconds }
        .thenByDescending { it.performanceNumber }
}

/**
 * Helper to map a StateFlow through a transform into a new StateFlow.
 * (Kotlin doesn't have this built-in; combine() only works with multiple flows.)
 */
private fun <T, R> StateFlow<T>.mapToState(
    scope: kotlinx.coroutines.CoroutineScope,
    transform: (T) -> R
): StateFlow<R> = this.map { transform(it) }
    .stateIn(scope, SharingStarted.WhileSubscribed(5000), transform(value))
