package com.ponzischeme89.memby.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.analytics.RowAnalytics
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

enum class HomeSection { CONTINUE, FAVORITES, LATEST }

data class HomeUiState(
    /**
     * Everything in progress, including the episode that follows one just finished:
     * Continue Watching and Next Up are one row. The merge happens on the gateway, or in
     * `EmbyRepository.getContinueWatching` on the direct path.
     */
    val continueWatching: List<BaseItem> = emptyList(),
    val favorites: List<BaseItem> = emptyList(),
    val latestMovies: List<BaseItem> = emptyList(),
    /**
     * Rows as composed by the gateway, including recommendation strips. Empty on the
     * direct-to-Emby path, where the client composes rows itself.
     */
    val rows: List<HomeRow> = emptyList(),
    val loading: Set<HomeSection> = HomeSection.entries.toSet(),
    val hasRefreshError: Boolean = false,
    /**
     * A message worth showing above the rows. Null means "use the generic
     * slow-connection wording".
     */
    val statusMessage: String? = null,
    /**
     * Set when the gateway reports a deliberate outage. Distinct from [statusMessage]
     * because this replaces the whole content area rather than adding a banner — the
     * rows behind it would be stale and unusable anyway.
     */
    val maintenanceMessage: String? = null,
) {
    /**
     * This state with everything that is *not* a row blanked out. Paired with
     * `distinctUntilChanged`, it turns [HomeViewModel.content] into a flow that only
     * emits when something changed about what is on the rows — see the note there.
     *
     * Read rows and [loading] from this; the blanked fields are meaningless in it.
     */
    fun contentSlice(): HomeUiState = copy(
        hasRefreshError = false,
        statusMessage = null,
        maintenanceMessage = null,
    )

    fun statusSlice(): HomeStatus = HomeStatus(
        hasRefreshError = hasRefreshError,
        statusMessage = statusMessage,
        maintenanceMessage = maintenanceMessage,
    )

    fun toCache() = HomeCache(
        continueWatching = continueWatching,
        favorites = favorites,
        latestMovies = latestMovies,
        rows = rows,
    )

    companion object {
        fun from(cache: HomeCache?) = HomeUiState(
            // A cache written before the two rows merged still carries its Next Up items
            // separately; folding them in keeps the cold-start row complete until the
            // first refresh replaces it with a properly interleaved one.
            continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty())
                .distinctBy(BaseItem::id),
            favorites = cache?.favorites.orEmpty().distinctItems(),
            latestMovies = cache?.latestMovies.orEmpty().distinctItems(),
            // A cache is the previous build's output as much as this one's, so it is
            // sanitised on the way in for the same reason a response is — see
            // [sanitisedRows]. This one is drawn before any request has been made, which
            // makes it the copy a duplicate would crash the app on every single launch.
            rows = cache?.rows.orEmpty().sanitisedRows(),
            loading = buildSet {
                if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
                if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES)
                if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST)
            },
        )
    }
}

/**
 * The connection-health slice of [HomeUiState]: what the banner and the maintenance
 * screen need, and nothing that would drag the rows into a recomposition with it.
 */
data class HomeStatus(
    val hasRefreshError: Boolean = false,
    val statusMessage: String? = null,
    val maintenanceMessage: String? = null,
)

data class ForYouUiState(
    val rows: List<HomeRow> = emptyList(),
    val availableMinutes: Int = 0,
    val loading: Boolean = false,
    val error: String? = null,
)

class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
    private val refreshMutex = Mutex()
    private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
    val state: StateFlow<HomeUiState> = _state.asStateFlow()

    // HomeScreen is a very large composable, so reading the whole of [state] there meant
    // every emission invalidated the launcher: the slow-
    // connection banner and each of the four section loads all recomposed the rows, the
    // rail and every overlay, and rebuilt the row list with them. These three narrow
    // projections let it subscribe only to what each part actually renders.

    /** Rows and their loading flags. Does not emit for banner changes. */
    val content: StateFlow<HomeUiState> = _state
        .map(HomeUiState::contentSlice)
        .distinctUntilChanged()
        .stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice())

    /** Connection health. Does not emit when rows change. */
    val status: StateFlow<HomeStatus> = _state
        .map(HomeUiState::statusSlice)
        .distinctUntilChanged()
        .stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.statusSlice())

    private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
    val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
    // Genre pages own paged copies that do not live in HomeUiState. This small mutation
    // stream lets those copies reflect a heart press immediately, including a rollback
    // when the server rejects it, without refreshing or losing the active category.
    private val _favoriteChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
    val favoriteChanges: StateFlow<Map<String, Boolean>> = _favoriteChanges.asStateFlow()
    private val _playedChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
    val playedChanges: StateFlow<Map<String, Boolean>> = _playedChanges.asStateFlow()
    private val _forYou = MutableStateFlow(ForYouUiState())
    val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
    private var metadataJob: Job? = null
    private var refreshJob: Job? = null
    private var forYouJob: Job? = null
    private var forYouRequestId = 0L
    private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
        override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
    }

    /** Row engagement, buffered here and uploaded in batches. */
    private val analytics = RowAnalytics()
    private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty())

    init {
        refreshAll()
        viewModelScope.launch {
            repository.playbackStops.collect { refreshWatching() }
        }
        viewModelScope.launch {
            // A D-pad produces focus changes far faster than anything should produce
            // HTTP requests, so engagement is uploaded on a slow drumbeat instead.
            while (true) {
                delay(ANALYTICS_FLUSH_INTERVAL_MS)
                flushAnalytics()
            }
        }
    }

    fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) =
        analytics.rowImpression(rowId, rowKind, visibleItemIds)

    fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
        analytics.rowFocused(rowId, rowKind, itemId)

    fun trackRowSelected(rowId: String, rowKind: String, itemId: String) =
        analytics.rowSelected(rowId, rowKind, itemId)

    /**
     * Closes the open dwell measurement and uploads. Called on a timer and when the home
     * screen stops, so time spent sitting on one row is not lost.
     */
    fun flushAnalytics() {
        analytics.endFocus()
        repository.reportRowEvents(analytics.drain())
        repository.reportJourneyEvents(journey.drain())
    }

    fun trackJourney(
        category: String, action: String, screen: String = "", feature: String = "",
        source: String = "", target: String = "", itemId: String = "", itemType: String = "",
        outcome: String = "",
    ) = journey.track(category, action, screen, feature, source, target, itemId, itemType, outcome)

    fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }

    suspend fun endJourneyBeforeProfileSwitch(screen: String) {
        analytics.endFocus()
        journey.track(
            category = "profile", action = "switch", screen = screen,
            feature = "profiles", source = screen, target = "profiles",
        )
        journey.end(screen)
        repository.reportAnalyticsBeforeProfileSwitch(analytics.drain(), journey.drain())
    }

    fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) {
        requestForYou(availableMinutes, clearFocus = true)
    }

    /**
     * Warms the dedicated destination once Home has its essential rows. Starting this
     * before the viewer opens For You hides the gateway round trip without making the
     * launcher's first response compete with another request.
     */
    private fun preloadForYou(availableMinutes: Int) {
        if (_forYou.value.rows.isNotEmpty()) return
        requestForYou(availableMinutes, clearFocus = false)
    }

    private fun requestForYou(availableMinutes: Int, clearFocus: Boolean) {
        val minutes = availableMinutes.coerceIn(0, 360)
        if (forYouJob?.isActive == true && _forYou.value.availableMinutes == minutes) {
            if (clearFocus) _focusedItem.value = null
            return
        }
        val requestId = ++forYouRequestId
        forYouJob?.cancel()
        if (clearFocus) _focusedItem.value = null
        _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
        forYouJob = viewModelScope.launch(Dispatchers.IO) {
            runCatching { repository.getForYou(minutes) }
                .onSuccess { built ->
                    if (requestId != forYouRequestId) return@onSuccess
                    val rows = built.sanitisedRows()
                    _forYou.value = ForYouUiState(
                        rows = rows,
                        availableMinutes = minutes,
                        loading = false,
                    )
                    if (clearFocus) {
                        rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem)
                    }
                }
                .onFailure { error ->
                    if (error is kotlinx.coroutines.CancellationException) throw error
                    if (requestId != forYouRequestId) return@onFailure
                    _forYou.update {
                        it.copy(
                            loading = false,
                            error = "For You is temporarily unavailable",
                        )
                    }
                }
        }
    }

    fun refreshAll() {
        // One recovery loop owns refreshes. Repeated Select presses while a request is
        // slow therefore do not queue a burst that hits the server after it recovers.
        if (refreshJob?.isActive == true) return
        refreshJob = viewModelScope.launch(Dispatchers.IO) {
            var retryDelayMs = HOME_RETRY_INITIAL_MS
            do {
                refreshMutex.withLock {
                    // A refresh can make a newly imported episode the next playable item for
                    // an existing series ID. Never launch the pre-refresh negotiated session.
                    repository.invalidatePlaybackPrefetch()
                    _state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) }
                    if (repository.supportsBatchHome) {
                        loadBatchHome()
                    } else {
                        coroutineScope {
                            launch { loadContinueWatching() }
                            launch { loadFavorites() }
                            launch { loadLatest() }
                        }
                    }
                    persistCurrentHome()
                }
                if (!_state.value.hasRefreshError) return@launch
                delay(retryDelayMs)
                retryDelayMs = (retryDelayMs * 2).coerceAtMost(HOME_RETRY_MAX_MS)
            } while (true)
        }
    }

    /**
     * The gateway returns every row in one response, so the four-way fan-out collapses
     * into a single request and the rows can no longer arrive out of step with each other.
     */
    private suspend fun loadBatchHome() {
        runCatching { repository.getHome() }
            .onSuccess { home ->
                val taggedHome = home.withAiringTodayTags()
                _state.update { current ->
                    current.copy(
                        continueWatching = taggedHome.continueWatching.distinctItems(),
                        favorites = taggedHome.favorites.distinctItems(),
                        latestMovies = taggedHome.latestMovies.distinctItems(),
                        // Recommendation rows are built in the background by the gateway,
                        // so an early response can arrive without them. Keeping the rows
                        // we already had stops the strip flickering out and back in.
                        rows = taggedHome.rows.sanitisedRows().ifEmpty { current.rows },
                        loading = emptySet(),
                        hasRefreshError = taggedHome.partial,
                        statusMessage = null,
                        // A successful response is the only thing that clears the
                        // maintenance screen, so a retry that fails keeps it up.
                        maintenanceMessage = null,
                    )
                }
                if (_focusedItem.value == null) {
                    initialFocusedItem(_state.value)?.let(::focusItem)
                }
                preloadForYou(repository.currentSettings.forYouMinutes)
                // Home may have been cached just before the background recommendation
                // build completed. Pull the dedicated endpoint after the fast home draw
                // so personalized Shows shelves appear on this visit, not a minute later.
                refreshRecommendationRows()
            }
            .onFailure { error ->
                if (error is kotlinx.coroutines.CancellationException) throw error
                val maintenance = isMaintenanceError(error)
                _state.update {
                    it.copy(
                        loading = emptySet(),
                        hasRefreshError = true,
                        statusMessage = null,
                        maintenanceMessage = if (maintenance) friendlyEmbyError(error) else null,
                    )
                }
            }
    }

    private suspend fun refreshRecommendationRows() {
        val fresh = runCatching { repository.getRecommendations() }
            .getOrElse { error ->
                if (error is kotlinx.coroutines.CancellationException) throw error
                return
            }
        _state.update { state ->
            val airingTodayKeys = state.rows.airingTodayShowKeys()
            val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
            // The three id shapes are what the recommendation build is *expected* to
            // replace. Anything else it returns under an id the launcher already holds is
            // still a replacement — and, left alone, would be a second LazyColumn item
            // under one key, which is a crash rather than a duplicate row. So the arriving
            // set wins by id, and the concatenation is sanitised regardless.
            val freshIds = taggedFresh.mapTo(mutableSetOf(), HomeRow::id)
            val fixedRows = state.rows.filterNot { row ->
                row.id == "recommended" ||
                    row.id.startsWith("similar:") ||
                    row.id.startsWith("curated:") ||
                    row.id in freshIds
            }
            state.copy(rows = (fixedRows + taggedFresh).sanitisedRows())
        }
    }

    /**
     * Updates local metadata immediately, then enriches it only after focus settles.
     * Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
     */
    fun focusItem(item: BaseItem) {
        val cached = synchronized(metadataCache) { metadataCache[item.id] }
        val focused = focusedItemWithMetadata(item, cached)
        _focusedItem.value = focused
        metadataJob?.cancel()
        metadataJob = viewModelScope.launch(Dispatchers.IO) {
            delay(FOCUS_METADATA_DEBOUNCE_MS)
            coroutineScope {
                // Resolution is tiny compared with video buffering and makes the later
                // Play click a memory lookup. The repository single-flights requests,
                // so focus and click can never duplicate the gateway call.
                if (item.membyPlayable) {
                    // The row owns live UserData. Prefetching the metadata-cache copy used
                    // to turn a 5% resume point into zero after details had been visited.
                    launch { runCatching { repository.prefetchPlayable(focused) } }
                }
                // Warm the explanation and franchise siblings while the card is already
                // focused, so opening Details does not add a reason line a frame later.
                if (!item.isSchedule && (item.isMovie || item.isSeries)) {
                    launch { runCatching { repository.getRelated(focused) } }
                }
                if (cached == null && !item.isSchedule) {
                    launch {
                        val details = runCatching {
                            repository.getItemDetails(item.id)
                        }.getOrNull() ?: return@launch
                        val taggedDetails = focusedItemWithMetadata(item, details)
                        synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
                        if (_focusedItem.value?.id == item.id) {
                            _focusedItem.value = taggedDetails
                        }
                    }
                }
                launch { warmDetailPage(item) }
            }
        }
    }

    /**
     * The two requests a detail page still opened cold, warmed while the card is focused.
     *
     * Everything else the page needs is already in hand by the time it opens — the item
     * record, its "why you might enjoy it" and its playable URL are all warmed above — but
     * the episode list and the trailer were not, so a series page opened with an empty
     * Episodes pane, no progress, no next episode and no estimated finish, and every page
     * opened with its trailer button missing until the network answered. Continue Watching
     * is the case that matters most: every card on the launcher's busiest row is an
     * episode, and all of them want the same show's list.
     *
     * **It waits longer than the metadata warm does**, and that is the whole cost control.
     * An episode list is the largest request the client makes — a long-running show is a
     * thousand records — so warming one per card as somebody scans across a shelf would
     * spend more bandwidth than it saves. This job is cancelled the moment the D-pad moves,
     * so a viewer travelling along a row never reaches it; one who has stopped on a card,
     * which is what precedes a press, does. Both requests are single-flighted and cached on
     * the repository, so the press that follows finds the answer rather than a second copy
     * of the request.
     */
    private suspend fun warmDetailPage(item: BaseItem) {
        if (item.isSchedule) return
        delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS)
        coroutineScope {
            // A series is keyed on itself, an episode on the show it belongs to — which is
            // exactly what its own detail page will ask for.
            val seriesId = when {
                item.isSeries -> item.id
                item.isEpisode -> item.seriesId
                else -> null
            }
            if (!seriesId.isNullOrBlank()) {
                launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
            }
            launch { runCatching { repository.getLocalTrailer(item.id) } }
        }
    }

    fun setFavorite(item: BaseItem, favorite: Boolean) {
        updateFavorite(item, favorite)
        viewModelScope.launch(Dispatchers.IO) {
            runCatching { repository.setFavorite(item.id, favorite) }
                .onSuccess { confirmed ->
                    updateFavorite(item, confirmed)
                }
                .onFailure {
                    updateFavorite(item, !favorite)
                }
        }
    }

    private fun updateFavorite(item: BaseItem, favorite: Boolean) {
        _favoriteChanges.update { it + (item.id to favorite) }
        updateUserData(item.id) { it.copy(isFavorite = favorite) }
        _state.update { state ->
            val updatedItem = item.copy(
                userData = (item.userData ?: UserItemData()).copy(isFavorite = favorite),
            )
            state.copy(
                favorites = if (favorite) {
                    (state.favorites + updatedItem).distinctBy(BaseItem::id)
                } else {
                    state.favorites.filterNot { it.id == item.id }
                },
            )
        }
    }

    fun setPlayed(item: BaseItem, played: Boolean) {
        val previousPlayed = item.userData?.played == true
        val previousPosition = item.userData?.playbackPositionTicks ?: 0L
        _playedChanges.update { it + (item.id to played) }
        updateUserData(item.id) {
            it.copy(
                played = played,
                playbackPositionTicks = if (played) 0L else it.playbackPositionTicks,
            )
        }
        viewModelScope.launch(Dispatchers.IO) {
            runCatching { repository.setPlayed(item.id, played) }
                .onSuccess { confirmed ->
                    _playedChanges.update { it + (item.id to confirmed) }
                    updateUserData(item.id) {
                        it.copy(
                            played = confirmed,
                            playbackPositionTicks = if (confirmed) 0L else it.playbackPositionTicks,
                        )
                    }
                }
                .onFailure {
                    _playedChanges.update { it + (item.id to previousPlayed) }
                    updateUserData(item.id) {
                        it.copy(played = previousPlayed, playbackPositionTicks = previousPosition)
                    }
                }
        }
    }

    fun removeFromContinueWatching(item: BaseItem) {
        val previous = _state.value
        _state.update { state ->
            state.copy(
                continueWatching = state.continueWatching.filterNot { it.id == item.id },
                rows = state.rows.map { row ->
                    if (row.kind == "continue" || row.kind == "nextup") {
                        row.copy(items = row.items.filterNot { it.id == item.id })
                    } else {
                        row
                    }
                },
            )
        }
        _focusedItem.update { focused -> focused?.takeUnless { it.id == item.id } }
        viewModelScope.launch(Dispatchers.IO) {
            runCatching { repository.removeFromContinueWatching(item.id) }
                .onSuccess { persistCurrentHome() }
                .onFailure {
                    _state.value = previous
                    _focusedItem.value = item
                }
        }
    }

    private fun updateUserData(itemId: String, transform: (UserItemData) -> UserItemData) {
        fun BaseItem.updated(): BaseItem =
            if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this

        _state.update {
            it.copy(
                continueWatching = it.continueWatching.map(BaseItem::updated),
                favorites = it.favorites.map(BaseItem::updated),
                latestMovies = it.latestMovies.map(BaseItem::updated),
                // Server rows hold their own copies of the same items, so an optimistic
                // favourite/watched toggle has to reach into them too or the heart on a
                // recommendation card would not light up.
                rows = it.rows.map { row -> row.copy(items = row.items.map(BaseItem::updated)) },
            )
        }
        _focusedItem.update { it?.updated() }
        synchronized(metadataCache) {
            metadataCache[itemId]?.let { metadataCache[itemId] = it.updated() }
        }
    }

    private suspend fun refreshWatching() {
        refreshMutex.withLock {
            _state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
            if (repository.supportsBatchHome) {
                // Playback just invalidated this user's rows on the gateway anyway.
                loadBatchHome()
            } else {
                loadContinueWatching(clearLoading = false)
            }
            _state.update { it.copy(loading = it.loading - HomeSection.CONTINUE) }
            persistCurrentHome()
        }
    }

    private suspend fun loadContinueWatching(clearLoading: Boolean = true) =
        load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items ->
            state.copy(continueWatching = items)
        }

    private suspend fun loadFavorites() =
        load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items ->
            state.copy(favorites = items)
        }

    private suspend fun loadLatest() =
        load(HomeSection.LATEST, true, { repository.getLatestMovies() }) { state, items ->
            state.copy(latestMovies = items)
        }

    private suspend fun load(
        section: HomeSection,
        clearLoading: Boolean,
        request: suspend () -> List<BaseItem>,
        updateItems: (HomeUiState, List<BaseItem>) -> HomeUiState,
    ) {
        runCatching { request() }
            .onSuccess { rawItems ->
                // The direct path's own fan-out gets the same treatment the gateway's
                // batch response does: whichever backend answered, two cards under one id
                // take the row's LazyRow down with them.
                val items = rawItems.distinctItems()
                _state.update { current ->
                    updateItems(current, items).let {
                        if (clearLoading) it.copy(loading = it.loading - section) else it
                    }
                }
                if (_focusedItem.value == null) {
                    items.firstOrNull()?.let(::focusItem)
                }
            }
            .onFailure {
                _state.update { current ->
                    current.copy(
                        loading = if (clearLoading) current.loading - section else current.loading,
                        hasRefreshError = true,
                    )
                }
            }
    }

    private suspend fun persistCurrentHome() {
        runCatching { repository.cacheHome(_state.value.toCache()) }
    }

    override fun onCleared() {
        flushAnalytics()
        super.onCleared()
    }

    companion object {
        private const val FOCUS_METADATA_DEBOUNCE_MS = 140L

        /**
         * How long focus must rest on a card before its detail page is warmed, measured
         * from the press that focused it. Deliberately well past
         * [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides
         * what the panel beside the row says, so it should follow the D-pad closely, while
         * this one can be a thousand episode records and should only follow a viewer who
         * has stopped. See [warmDetailPage].
         */
        private const val DETAIL_PREFETCH_DELAY_MS = 450L
        private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
        private const val HOME_RETRY_INITIAL_MS = 2_000L
        private const val HOME_RETRY_MAX_MS = 60_000L

        private fun initialFocusedItem(state: HomeUiState): BaseItem? =
            state.continueWatching.firstOrNull()
                ?: state.latestMovies.firstOrNull()
                ?: state.favorites.firstOrNull()
    }

}

/**
 * Adds rich detail metadata without replacing the row's live per-user state.
 *
 * Continue Watching is the important case: its card knows the current playhead, while a
 * cached/full metadata record may carry no UserData at all. Losing that state changes the
 * button from Resume to Play and sends a zero resume position into playback.
 */
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
    (metadata ?: item).copy(
        userData = item.userData ?: metadata?.userData,
        membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
        membyRecommendationReason = item.membyRecommendationReason
            ?: metadata?.membyRecommendationReason,
        membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
    )

internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
    val airingTodayKeys = rows.airingTodayShowKeys()
    if (airingTodayKeys.isEmpty()) return this
    val taggedContinue = continueWatching
        .withAiringTodayItemTags(airingTodayKeys)
        .prioritizeAiringToday()
    return copy(
        rows = rows.withAiringTodayRowTags(airingTodayKeys).map { row ->
            if (row.id == "continue") {
                row.copy(items = row.items.prioritizeAiringToday())
            } else {
                row
            }
        },
        continueWatching = taggedContinue,
        favorites = favorites.withAiringTodayItemTags(airingTodayKeys),
        latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys),
    )
}

private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
    firstOrNull { it.id == "sonarr-airing-today" }
        ?.items
        .orEmpty()
        .filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) }
        .mapTo(mutableSetOf()) { it.name.showMatchKey() }
        .filterTo(mutableSetOf(), String::isNotEmpty)

private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRow> =
    map { row -> row.copy(items = row.items.withAiringTodayItemTags(keys)) }

private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
    map { item ->
        val showName = when {
            item.isEpisode -> item.seriesName
            item.isSeries -> item.name
            else -> null
        }
        if (!item.isTvSchedule && showName != null && showName.showMatchKey() in keys) {
            item.copy(membyAiringToday = true)
        } else {
            item
        }
    }

/** Stable partition: today's shows move forward without disturbing recency within groups. */
private fun List<BaseItem>.prioritizeAiringToday(): List<BaseItem> =
    filter(BaseItem::membyAiringToday) + filterNot(BaseItem::membyAiringToday)

private fun String.showMatchKey(): String =
    lowercase().filter(Char::isLetterOrDigit)

class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        require(modelClass.isAssignableFrom(HomeViewModel::class.java))
        return HomeViewModel(repository) as T
    }
}
