package com.ponzischeme89.memby.data

import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvent
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
import com.ponzischeme89.memby.data.model.GatewayJourneyEvents
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
import com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.MediaSourceInfo
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import com.ponzischeme89.memby.data.remote.TrickplayClient
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException
import java.net.URLEncoder

/**
 * One batch home response. [partial] means at least one row failed upstream and the rest
 * is still worth rendering.
 */
data class HomeSnapshot(
    /** Rows exactly as the server composed them, in display order. */
    val rows: List<HomeRow> = emptyList(),
    val continueWatching: List<BaseItem> = emptyList(),
    val nextUp: List<BaseItem> = emptyList(),
    val favorites: List<BaseItem> = emptyList(),
    val latestMovies: List<BaseItem> = emptyList(),
    val partial: Boolean = false,
)

/**
 * The episode that follows the one being watched, with everything the "next up" banner
 * needs to render itself and then start playing without a second round trip.
 */
data class NextEpisode(
    val itemId: String,
    val title: String,
    val seriesName: String,
    val episodeCode: String?,
    val imageUrl: String?,
    val logoUrl: String? = null,
    val overview: String = "",
    val runtimeMs: Long = 0L,
    val url: String,
    val resumePositionMs: Long = 0L,
    val subtitles: List<PlayableSubtitle> = emptyList(),
    val subtitlesEnabled: Boolean = true,
    val selectedSubtitleId: String = "",
    val mediaSourceId: String = "",
    val playSessionId: String = "",
    val playMethod: String = "DirectPlay",
    val subtitleDownloadAvailable: Boolean = false,
    val trickplayAvailable: Boolean = false,
    val skipIntroAvailable: Boolean = false,
    val endCreditsAvailable: Boolean = false,
)

/** A resolved, directly playable stream. */
data class Playable(
    val itemId: String,
    val title: String,
    val url: String,
    val resumePositionMs: Long = 0L,
    val logoUrl: String? = null,
    val subtitles: List<PlayableSubtitle> = emptyList(),
    /**
     * Which subtitle track to turn on, decided from the viewer's synced settings. On the
     * gateway path the server decides it, because that is what asks Emby which tracks
     * exist; on the direct path the same rule runs here. Blank means "none suitable".
     */
    val subtitlesEnabled: Boolean = true,
    val selectedSubtitleId: String = "",
    val mediaSourceId: String = "",
    val playSessionId: String = "",
    val playMethod: String = "DirectPlay",
    val overview: String? = null,
    val episodeCode: String? = null,
    val runtimeMs: Long = 0L,
    val prerollEnabled: Boolean = true,
    val prerollDurationMs: Long = 6_500L,
    /**
     * Whether the backend can fetch a subtitle this title does not have. Only the gateway
     * can - the direct path has nobody to ask - and it stays false when an operator has
     * turned the feature off, so the player never offers a row that cannot do anything.
     */
    val subtitleDownloadAvailable: Boolean = false,
    /** Whether this title has a second readable track against which timing can be checked. */
    val subtitleFixAvailable: Boolean = false,
    /**
     * Whether it is worth asking the backend for seek previews. On the direct path the
     * television reads Emby's preview file itself, so this is always true there; in
     * gateway mode it is the gateway saying whether it will answer, which keeps an older
     * or a deliberately-configured-off container from being asked once per playback.
     */
    val trickplayAvailable: Boolean = false,
    /**
     * Whether it is worth asking the backend where this title's opening titles are. True
     * on the direct path, where the television reads Emby's chapter markers itself; in
     * gateway mode it is the gateway saying whether it will answer, which keeps an older
     * or a deliberately-configured-off container from being asked once per playback.
     */
    val skipIntroAvailable: Boolean = false,
    /**
     * Whether it is worth asking the backend where this title's closing credits begin. True
     * on the direct path for the same reason the above is, and in gateway mode it is the
     * gateway's own switch. Its own field rather than a reuse of [skipIntroAvailable]: the
     * two are separate features, and an operator turning the skip button off has not asked
     * to lose the credits pane with it.
     */
    val endCreditsAvailable: Boolean = false,
)

/**
 * What a subtitle search came back with.
 *
 * An empty list is an ordinary answer, not a failure - the providers may simply have
 * nothing for this release - so [message] is what the drop-up prints in its place. It
 * comes from the gateway when the gateway answered, because "nothing was found" and
 * "Memby could not work out which title this is" are different things to be told.
 */
data class SubtitleSearch(
    val results: List<GatewaySubtitleCandidate> = emptyList(),
    val message: String = "",
)

/**
 * A downloaded subtitle, with the item's tracks as Emby reports them after being told to
 * look again. The player swaps its media item to these rather than re-resolving the
 * stream, so the new track comes on without leaving the film.
 */
data class SubtitleDownload(
    val message: String,
    val subtitles: List<PlayableSubtitle>,
    val selectedSubtitleId: String,
    val mediaSourceId: String,
    val playSessionId: String,
    val url: String,
)

/** The gateway's answer after checking one existing subtitle against another. */
data class SubtitleFix(
    val subtitleId: String,
    val message: String,
    val changed: Boolean,
    val offsetMs: Long,
    val reference: String,
)

/**
 * Everything needed to resolve a stream, plus everything the player needs to dress its
 * loading screen while that resolution is still in flight.
 *
 * It exists so that pressing Play can open the player *before* the server has answered.
 * Starting an activity, inflating its layout and initialising a video decoder all cost
 * real time on a television, and there is no reason for them to queue up behind a network
 * round trip that they do not depend on. Everything here is already known to the launcher
 * from the card the viewer was looking at.
 *
 * [resumePositionMs] is the client's last known resume point, which is what the card
 * displayed; the server's answer remains authoritative for where playback actually starts.
 * It is carried because the pre-roll decision cannot wait for that answer — see
 * `shouldShowPreroll`.
 */
@Serializable
data class PlaybackRequest(
    val itemId: String,
    val itemType: String = "",
    val title: String = "",
    val isSeries: Boolean = false,
    val resumePositionMs: Long = 0L,
    val logoUrl: String? = null,
    val overview: String? = null,
    val episodeCode: String? = null,
    val runtimeMs: Long = 0L,
)

class EmbyRepository(private val settings: SettingsStore) {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    @Volatile
    private var observedSettings: Settings = Settings.EMPTY
    private val snapshot: Settings
        get() = settings.current ?: observedSettings

    init {
        scope.launch { settings.settingsFlow.collect { observedSettings = it } }
    }

    val settingsFlow: Flow<Settings> get() = settings.settingsFlow

    /**
     * What the settings are *right now*, for a composable that has to draw before a flow
     * can emit. A detail page opened with [Settings.EMPTY] as its initial value draws its
     * first frame under default preferences and then recomposes the whole page — and
     * restarts the effects keyed on those preferences — one frame later, at precisely the
     * moment the page is trying to appear. Reading the snapshot instead makes the first
     * frame the right one. Same reasoning as [showTitleLogo].
     */
    val currentSettings: Settings get() = snapshot

    /**
     * Read synchronously off the snapshot, like [rotationIntervalMillis], because the
     * composables that decide between logo artwork and a text title do so while building a
     * layout and must not collect a flow to answer a question that changes once a year.
     */
    val showTitleLogo: Boolean get() = snapshot.showTitleLogo
    private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
    val playbackStops = _playbackStops.asSharedFlow()
    private val playableMutex = Mutex()
    private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
    private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
    /**
     * Preview layouts, per title, for as long as the process lives. A title's thumbnails
     * only change when its media does, and the entry is what saves a round trip on every
     * press of Left or Right.
     */
    private val trickplayCache =
        LinkedHashMap<String, CachedTrickplay>(TRICKPLAY_CACHE_SIZE, 0.75f, true)
    /**
     * Where each episode's opening titles are, for as long as the process lives. Markers
     * only change when the media is re-analysed, and an evening of one show asks for this
     * once per episode — including the ones auto-advance rolls into.
     */
    private val introCache = LinkedHashMap<String, CachedIntro>(INTRO_CACHE_SIZE, 0.75f, true)
    private val seriesEpisodesMutex = Mutex()
    private val seriesEpisodesCache =
        LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
    private val seriesEpisodesInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
    private val relatedMutex = Mutex()
    private val relatedCache =
        LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
    private val relatedInFlight = mutableMapOf<String, Deferred<RelatedContent?>>()
    private val trailerMutex = Mutex()
    /**
     * Whether a title has a local trailer, for as long as the process lives. A null [value]
     * is the answer for most of a library, which is exactly why the wrapper exists: without
     * it an absent entry and "no trailer" are the same thing, and every detail page would
     * ask the gateway a question it has already answered 404 to.
     */
    private val trailerCache =
        LinkedHashMap<String, CachedTrailer>(TRAILER_CACHE_SIZE, 0.75f, true)
    private val trailerInFlight = mutableMapOf<String, Deferred<CachedTrailer?>>()

    fun cachedHome(): HomeCache? = settings.homeCache(snapshot)

    suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)

    suspend fun setForYouMinutes(minutes: Int) = settings.setForYouMinutes(minutes)

    suspend fun markForYouOpened() = settings.markForYouOpened()

    // --- API instance caching (rebuilt only when the server URL changes) -----

    private var cachedApi: EmbyApi? = null
    private var cachedBaseUrl: String? = null

    /**
     * The Memby gateway, when this build has one. Its address is fixed at build time, so
     * unlike the Emby client this never needs rebuilding.
     */
    private val gatewayApi: GatewayApi? by lazy {
        ServerConfig.gatewayUrl?.let { url ->
            GatewayServiceFactory.create(url) { snapshot.token }
        }
    }

    private fun requireGateway(): GatewayApi = gatewayApi ?: error("No Memby gateway configured")

    /** True when the backend can return the whole home screen in one request. */
    val supportsBatchHome: Boolean get() = ServerConfig.isGateway

    private fun apiFor(serverUrl: String): EmbyApi {
        val base = normalizeServerUrl(serverUrl)
        cachedApi?.let { if (cachedBaseUrl == base) return it }
        val api = EmbyServiceFactory.create(
            baseUrl = base,
            deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
            tokenProvider = { snapshot.token },
        )
        cachedApi = api
        cachedBaseUrl = base
        return api
    }

    /**
     * The server all requests and media URLs point at. A build that hardwires an address
     * (see [ServerConfig]) always wins, so repointing every install is a property change
     * plus a reinstall — no user action, and no stale address left in a saved session.
     */
    private val activeServerUrl: String? get() = ServerConfig.hardwiredUrl ?: snapshot.serverUrl

    private fun requireApi(): EmbyApi {
        val url = activeServerUrl ?: error("Not connected to a server")
        return apiFor(url)
    }

    // --- Authentication ------------------------------------------------------

    /**
     * Signs in. [serverUrl] is only consulted when the build does not hardwire one —
     * with a hardwired address the setup screen never collects it.
     */
    suspend fun authenticate(
        serverUrl: String,
        username: String,
        password: String,
        deviceName: String,
    ): String {
        clearPlayableCache()
        clearSeriesEpisodeCache()
        settings.ensureDeviceId()
        observedSettings = settings.snapshot() // pick up the freshly-generated device id

        ServerConfig.gatewayUrl?.let { gateway ->
            // The gateway holds the Emby token; this device only ever stores the gateway
            // token, so the same `token` slot in DataStore serves both modes.
            val result = requireGateway().login(
                    GatewayLoginRequest(
                        username = username,
                        password = password,
                        deviceId = snapshot.deviceId.ifEmpty { "memby" },
                        deviceName = deviceName.trim(),
                    ),
                )
            require(result.token.isNotBlank() && result.userId.isNotBlank()) {
                "Gateway did not return a session"
            }
            val authenticatedUsername = result.username.ifBlank { username }
            settings.saveSession(
                gateway,
                result.token,
                result.userId,
                authenticatedUsername,
                result.serverId.takeIf { it.isNotBlank() },
            )
            settings.setDeviceName(deviceName)
            observedSettings = settings.snapshot()
            return authenticatedUsername
        }

        val base = resolveServerUrl(ServerConfig.hardwiredUrl, serverUrl)
            ?: error("No Emby server address configured")
        val api = apiFor(base)
        val result = api.authenticate(AuthRequest(username = username, pw = password))
        val token = result.accessToken
        val userId = result.user?.id
        require(!token.isNullOrBlank() && !userId.isNullOrBlank()) {
            "Server did not return an access token"
        }
        settings.saveSession(base, token, userId, username, result.serverId)
        settings.setDeviceName(deviceName)
        observedSettings = settings.snapshot()
        return username
    }

    suspend fun devices(): List<GatewayDevice> =
        if (ServerConfig.isGateway) requireGateway().devices().devices else emptyList()

    suspend fun removeDevice(deviceId: String) {
        if (ServerConfig.isGateway) requireGateway().removeDevice(deviceId)
    }

    suspend fun renameDevice(device: GatewayDevice, deviceName: String) {
        val trimmed = deviceName.trim()
        require(trimmed.isNotEmpty() && trimmed.length <= 80) { "Invalid device name" }
        if (ServerConfig.isGateway) {
            requireGateway().renameDevice(device.deviceId, GatewayDeviceNameRequest(trimmed))
            if (device.current) settings.setDeviceName(trimmed)
        }
    }

    /**
     * False only when the gateway explicitly rejects the restored token with 401.
     *
     * A deployment, maintenance window, or disconnected TV is not evidence that a
     * viewer's credentials are invalid. Treating every network failure as rejection used
     * to delete the active profile precisely when the server was being updated.
     */
    suspend fun validateSession(): Boolean {
        if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true
        return try {
            requireGateway().session()
            true
        } catch (cancelled: CancellationException) {
            throw cancelled
        } catch (failure: Throwable) {
            shouldPreserveSessionAfterValidationFailure(failure)
        }
    }

    /**
     * A 401 is authoritative: cached content must not masquerade as a slow connection.
     * Remove only the rejected profile, then the root UI naturally returns to sign-in.
     */
    suspend fun invalidateSession() {
        settings.invalidateActiveSession()
        observedSettings = settings.snapshot()
        cachedApi = null
        cachedBaseUrl = null
        clearPlayableCache()
        clearSeriesEpisodeCache()
    }

    suspend fun signOut() {
        // Retire the gateway token server-side too, so a lost TV can't keep reading the
        // library. A failure here must not block the local sign-out.
        if (ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) {
            runCatching { requireGateway().logout() }
        }
        settings.clearSession()
        observedSettings = settings.snapshot()
        cachedApi = null
        cachedBaseUrl = null
        clearPlayableCache()
        clearSeriesEpisodeCache()
    }

    suspend fun switchProfile(profile: EmbyProfile) {
        settings.switchProfile(profile)
        observedSettings = settings.snapshot()
        cachedApi = null
        cachedBaseUrl = null
        clearPlayableCache()
        clearSeriesEpisodeCache()
    }

    suspend fun removeProfile(profile: EmbyProfile) {
        val removingActiveProfile = snapshot.activeProfileId == profile.id
        if (removingActiveProfile && ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) {
            // Retire the active gateway session where possible. Local removal still
            // succeeds when this TV is offline.
            runCatching { requireGateway().logout() }
        }
        settings.removeProfile(profile.id)
        observedSettings = settings.snapshot()
        if (removingActiveProfile) {
            cachedApi = null
            cachedBaseUrl = null
            clearPlayableCache()
            clearSeriesEpisodeCache()
        }
    }

    // --- Content -------------------------------------------------------------

    /**
     * The whole home screen in one request. Only available against a gateway — check
     * [supportsBatchHome] first.
     */
    suspend fun getHome(limit: Int = 24): HomeSnapshot {
        val home = requireGateway().home(limit)
        return HomeSnapshot(
            rows = home.rows,
            continueWatching = home.continueWatching,
            nextUp = home.nextUp,
            favorites = home.favorites,
            latestMovies = home.latestMovies,
            partial = home.partial,
        )
    }

    /** A shuffled set of movies & shows that actually have a backdrop image. */
    suspend fun getScreensaverItems(limit: Int = 200): List<BaseItem> {
        if (ServerConfig.isGateway) {
            return requireGateway().screensaver(limit).items.filter {
                it.id.isNotBlank() && hasBackdrop(it)
            }
        }
        val userId = snapshot.userId ?: error("Not connected")
        val result = requireApi().getItems(
            userId,
            mapOf(
                "IncludeItemTypes" to "Movie,Series",
                "Recursive" to "true",
                "SortBy" to "Random",
                "Limit" to limit.toString(),
                "Fields" to "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks",
                "ImageTypeLimit" to "1",
                "EnableImageTypes" to "Backdrop,Logo",
                "EnableUserData" to "true",
            ),
        )
        return result.items.filter { it.id.isNotBlank() && hasBackdrop(it) }
    }

    /**
     * A deliberately tiny, movie-only request used during a cold start. It gives the
     * UI a genuine Emby backdrop while the larger mixed library queue is still loading.
     */
    suspend fun getStartupBackdropMovie(): BaseItem? {
        if (ServerConfig.isGateway) {
            // The gateway keeps a warm, cached backdrop pool, so the "tiny first query"
            // trick the direct path needs is unnecessary here.
            return requireGateway().screensaver(limit = 1).items.firstOrNull { hasBackdrop(it) }
        }
        val userId = snapshot.userId ?: error("Not connected")
        return requireApi().getItems(
            userId,
            mapOf(
                "IncludeItemTypes" to "Movie",
                "Recursive" to "true",
                "Filters" to "HasBackdrop",
                "SortBy" to "Random",
                "Limit" to "1",
                "Fields" to "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,RunTimeTicks",
                "ImageTypeLimit" to "1",
                "EnableImageTypes" to "Backdrop,Logo",
                "EnableUserData" to "true",
            ),
        ).items.firstOrNull { hasBackdrop(it) }
    }

    // Against a gateway the per-row getters just slice the batch response, which Redis
    // has already answered once for this user. They exist so the direct-to-Emby path and
    // any caller that wants a single row keep working unchanged.

    /** First home-page slice of favorited movies & shows. */
    suspend fun getFavorites(limit: Int = 24): List<BaseItem> {
        if (ServerConfig.isGateway) return getHome(limit).favorites
        return getHomeItems(
            params = mapOf(
                "Filters" to "IsFavorite",
                "IncludeItemTypes" to "Movie,Series",
                "Recursive" to "true",
                "SortBy" to "SortName",
                "SortOrder" to "Ascending",
                "Limit" to limit.toString(),
            ),
            fields = "ProductionYear,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio",
            imageTypes = "Backdrop,Primary,Logo",
            includeUserData = true,
        )
    }

    /**
     * Everything in progress: unfinished movies and episodes, interleaved with the episode
     * that follows each show somebody has just finished.
     *
     * They are one row — see [mergeContinueWatching]. The gateway does this merge itself;
     * this is the direct path's copy, and the three requests run together because an
     * episode's place in the row depends on when its series was last watched.
     */
    suspend fun getContinueWatching(limit: Int = 24): List<BaseItem> {
        if (ServerConfig.isGateway) return getHome(limit).continueWatching
        val userId = snapshot.userId ?: error("Not connected")
        return coroutineScope {
            val resume = async {
                requireApi().getResumeItems(
                    userId,
                    mapOf(
                        "Recursive" to "true",
                        "MediaTypes" to "Video",
                        "Limit" to limit.toString(),
                        "Fields" to "RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
                        "ImageTypeLimit" to "1",
                        "EnableImageTypes" to "Backdrop,Primary,Logo",
                        "EnableUserData" to "true",
                    ),
                ).items
            }
            val nextUp = async { runCatching { getNextUp(limit) }.getOrDefault(emptyList()) }
            // What orders the Next Up half. A failure here is not a failed row: the merge
            // falls back to the resume items first.
            val played = async { runCatching { recentlyPlayedSeries() }.getOrDefault(emptyMap()) }
            mergeContinueWatching(resume.await(), nextUp.await(), played.await())
        }
    }

    /**
     * When anything in each series was last played, keyed by series id. A Next Up episode
     * is unwatched and so carries no date of its own; this is what places it in the row.
     */
    private suspend fun recentlyPlayedSeries(): Map<String, String> {
        val userId = snapshot.userId ?: error("Not connected")
        val played = requireApi().getItems(
            userId,
            mapOf(
                "IncludeItemTypes" to "Episode",
                "Recursive" to "true",
                "Filters" to "IsPlayed",
                "SortBy" to "DatePlayed",
                "SortOrder" to "Descending",
                "Limit" to CONTINUE_PLAY_LOOKBACK.toString(),
                "Fields" to "SeriesId",
                "EnableImages" to "false",
                "EnableUserData" to "true",
                "EnableTotalRecordCount" to "false",
            ),
        ).items
        // Emby answers most-recently-played first, so the first entry seen for a series is
        // the one that counts.
        val recent = LinkedHashMap<String, String>()
        for (item in played) {
            val seriesId = item.seriesId?.takeIf(String::isNotBlank) ?: continue
            val at = normalizePlayedAt(item.userData?.lastPlayedDate) ?: continue
            recent.putIfAbsent(seriesId, at)
        }
        return recent
    }

    /** Episodes Emby recommends playing next. Merged into Continue Watching for display. */
    suspend fun getNextUp(limit: Int = 24): List<BaseItem> {
        if (ServerConfig.isGateway) return getHome(limit).nextUp
        val userId = snapshot.userId ?: error("Not connected")
        return requireApi().getNextUp(
            mapOf(
                "UserId" to userId,
                "Limit" to limit.toString(),
                "Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
                "ImageTypeLimit" to "1",
                "EnableImageTypes" to "Backdrop,Primary,Logo",
                "EnableTotalRecordCount" to "false",
                "EnableUserData" to "true",
            ),
        ).items
    }

    /** Recently added films, used as a compact discovery row on the client home. */
    suspend fun getLatestMovies(limit: Int = 24): List<BaseItem> {
        if (ServerConfig.isGateway) return getHome(limit).latestMovies
        return getHomeItems(
            params = mapOf(
                "IncludeItemTypes" to "Movie",
                "Recursive" to "true",
                "SortBy" to "DateCreated",
                "SortOrder" to "Descending",
                "Limit" to limit.toString(),
            ),
            fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio",
            imageTypes = "Backdrop,Primary,Logo",
            includeUserData = true,
        )
    }

    /** Library-wide search. Gateway-only: the direct path has no search UI behind it. */
    /**
     * Library search, dual-path like everything else.
     *
     * The gateway answers from its imported Postgres copy (falling back to Emby before
     * the first import finishes); direct mode asks Emby itself. Both return whatever
     * relevance order the backend chose — the caller re-ranks locally rather than
     * re-sorting here, so the ordering rule stays a pure, testable function.
     */
    suspend fun search(term: String, limit: Int = 40): List<BaseItem> {
        val trimmed = term.trim()
        if (trimmed.isEmpty()) return emptyList()
        if (ServerConfig.isGateway) return requireGateway().search(trimmed, limit).items
        return getHomeItems(
            params = mapOf(
                "SearchTerm" to trimmed,
                "IncludeItemTypes" to "Movie,Series,Episode",
                "Recursive" to "true",
                "Limit" to limit.toString(),
            ),
            fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
            imageTypes = "Backdrop,Primary,Logo",
            includeUserData = true,
        )
    }

    /**
     * One page of a genre, dual-path like the search beside it.
     *
     * This is a *filter*, not a query. Running a genre's name through search matched a film
     * called Drama, anything with the word in its overview, and — relevance being a score
     * rather than a rule — a scattering of titles not in the genre at all, while missing
     * most of the ones that were. Both paths therefore ask their backend for the genre.
     *
     * Episodes are excluded on purpose: an episode inherits its series' genres, so a page
     * would otherwise fill with twenty entries of one comedy and bury the rest.
     *
     * The gateway path degrades to a keyword search rather than throwing, so a television
     * on this build talking to a gateway that predates the route still shows a viewer
     * *something* when they press a genre — which is exactly what it did before.
     */
    suspend fun browseGenre(
        genre: String,
        offset: Int = 0,
        limit: Int = GENRE_PAGE_SIZE,
        itemType: String? = null,
    ): GenrePage {
        val trimmed = genre.trim()
        if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
        val embyItemType = when (itemType?.trim()?.lowercase()) {
            null, "" -> "Movie,Series"
            "movie" -> "Movie"
            "series" -> "Series"
            else -> "Movie,Series"
        }
        if (ServerConfig.isGateway) {
            runCatching {
                requireGateway().genreItems(
                    trimmed,
                    offset,
                    limit,
                    embyItemType.takeUnless { it == "Movie,Series" },
                )
            }
                .onSuccess { page ->
                    return GenrePage(page.items, offset, page.total)
                }
                .onFailure { error ->
                    if (error is kotlinx.coroutines.CancellationException) throw error
                    // Only the first page falls back. A gateway that answered page one and
                    // failed on page two is having trouble, not missing the route, and a
                    // search's results pasted onto the end of a genre would be nonsense.
                    if (offset > 0) throw error
                    // An older gateway understands neither this route nor Emby's pipe
                    // syntax. Searching the first member at least leaves a useful shelf;
                    // sending "Action|Adventure" as prose would usually return nothing.
                    val items = search(trimmed.substringBefore('|'), limit).filter { candidate ->
                        embyItemType == "Movie,Series" || candidate.type.equals(embyItemType, ignoreCase = true)
                    }
                    return GenrePage(items, offset, items.size)
                }
        }
        val items = getHomeItems(
            params = mapOf(
                "Genres" to trimmed,
                "IncludeItemTypes" to embyItemType,
                "Recursive" to "true",
                "StartIndex" to offset.toString(),
                "Limit" to limit.toString(),
                // The second sort key is what makes paging safe: with only a date, two
                // titles sharing one could swap places between requests and the scroll
                // would repeat one card and never show the other.
                "SortBy" to "PremiereDate,SortName",
                "SortOrder" to "Descending",
            ),
            fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
            imageTypes = "Backdrop,Primary,Logo",
            includeUserData = true,
        )
        // Emby's count is turned off for these list calls, so the page itself is the only
        // evidence: a full page means there may be more, a short one is the end.
        val total = offset + items.size + if (items.size >= limit) 1 else 0
        return GenrePage(items, offset, total)
    }

    /**
     * One stable, paged slice of all films or all series.
     *
     * This deliberately mirrors [browseGenre] so switching between All and a genre never
     * changes the grid's ordering, user-data fields, or infinite-scroll behaviour.
     */
    suspend fun browseLibrary(
        offset: Int = 0,
        limit: Int = GENRE_PAGE_SIZE,
        itemType: String,
    ): GenrePage {
        val embyItemType = if (itemType.trim().equals("Series", ignoreCase = true)) "Series" else "Movie"
        if (ServerConfig.isGateway) {
            runCatching { requireGateway().libraryItems(offset, limit, embyItemType) }
                .onSuccess { page -> return GenrePage(page.items, offset, page.total) }
                .onFailure { error ->
                    if (error is kotlinx.coroutines.CancellationException) throw error
                    if (offset > 0) throw error
                    // An older gateway has no whole-library route. Its home response is a
                    // useful first shelf and, crucially, is not claimed to be pageable.
                    val home = getHome(limit)
                    val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
                        home.continueWatching.asSequence() + home.nextUp.asSequence() +
                        home.favorites.asSequence() + home.latestMovies.asSequence())
                        .filter { it.type.equals(embyItemType, ignoreCase = true) }
                        .distinctBy(BaseItem::id)
                        .take(limit)
                        .toList()
                    return GenrePage(items, offset, items.size)
                }
        }
        val items = getHomeItems(
            params = mapOf(
                "IncludeItemTypes" to embyItemType,
                "Recursive" to "true",
                "StartIndex" to offset.toString(),
                "Limit" to limit.toString(),
                "SortBy" to "PremiereDate,SortName",
                "SortOrder" to "Descending",
            ),
            fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
            imageTypes = "Backdrop,Primary,Logo",
            includeUserData = true,
        )
        val total = offset + items.size + if (items.size >= limit) 1 else 0
        return GenrePage(items, offset, total)
    }

    /** Records a successful gateway search without affecting the direct Emby path. */
    suspend fun recordSearch(term: String) {
        if (ServerConfig.isGateway && term.trim().length >= 2) {
            runCatching { requireGateway().recordSearch(mapOf("query" to term.trim())) }
        }
    }

    /** Memby's database has no search history; this is a persisted Memby gateway feature. */
    suspend fun getRecentSearches(): List<String> {
        if (!ServerConfig.isGateway) return emptyList()
        return runCatching { requireGateway().recentSearches().queries }
            .getOrDefault(emptyList())
    }

    /**
     * One month of the TV calendar, or an unavailable month when there is nobody to ask.
     *
     * This is deliberately gateway-only and has no second implementation on the direct
     * path, unlike the subtitle and Continue Watching rules: the answer comes from Sonarr,
     * which a television holds no credential for and Emby knows nothing about. With no
     * gateway there is genuinely no calendar, and saying so is the honest degradation —
     * the rail entry is hidden in that case anyway.
     *
     * A blank [month] asks for the household's present month. The television never derives
     * one from its own clock, so the header can never disagree with the grid beneath it.
     */
    suspend fun getCalendarMonth(month: String = ""): GatewayCalendar {
        if (!ServerConfig.isGateway) return GatewayCalendar()
        val calendar = requireGateway().calendar(month.trim().takeIf { it.isNotEmpty() })
        return calendar.copy(
            days = calendar.days.map { day ->
                day.copy(items = day.items.filter { it.id.isNotBlank() }.distinctBy(BaseItem::id))
            },
        )
    }

    suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
        if (!ServerConfig.isGateway) return emptyList()
        return requireGateway().requestLookup(term.trim()).candidates
    }

    suspend fun requestMedia(
        candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
    ): String {
        check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
        return requireGateway().requestMedia(
            com.ponzischeme89.memby.data.model.GatewayMediaRequest(
                mediaType = candidate.mediaType,
                foreignId = candidate.foreignId,
            ),
        ).title
    }

    /**
     * Recommendation rows on their own, forcing the gateway to build them synchronously
     * if its cache is cold. [getHome] already carries them once warm, so this is only
     * needed to pull them in without a full home refresh.
     */
    suspend fun getRecommendations(): List<HomeRow> = requireGateway().recommendations().rows

    /** Tracearr-powered, request-scoped picks for the dedicated TV destination. */
    suspend fun getForYou(availableMinutes: Int): List<HomeRow> {
        if (!ServerConfig.isGateway) return emptyList()
        return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows
    }

    suspend fun getRecommendationOnboarding():
        com.ponzischeme89.memby.data.model.RecommendationOnboarding {
        if (!ServerConfig.isGateway) {
            return com.ponzischeme89.memby.data.model.RecommendationOnboarding(completed = true)
        }
        return requireGateway().recommendationPreferences()
    }

    suspend fun saveRecommendationRatings(
        ratings: Map<String, Int>,
        actors: List<String> = emptyList(),
        actresses: List<String> = emptyList(),
        directors: List<String> = emptyList(),
    ) {
        if (!ServerConfig.isGateway) return
        requireGateway().saveRecommendationPreferences(
            com.ponzischeme89.memby.data.model.RecommendationPreferences(
                ratings = ratings.filterValues { it in 1..5 },
                actors = actors,
                actresses = actresses,
                directors = directors,
            ),
        )
    }

    suspend fun getMyShows(): List<com.ponzischeme89.memby.data.model.MyShow> =
        if (ServerConfig.isGateway) {
            requireGateway().myShows().shows.filter { it.itemId.isNotBlank() }
        } else {
            emptyList()
        }

    suspend fun saveMyShow(item: BaseItem): List<com.ponzischeme89.memby.data.model.MyShow> {
        if (!ServerConfig.isGateway || !item.isSeries) return getMyShows()
        return requireGateway().saveMyShow(
            com.ponzischeme89.memby.data.model.SaveMyShowRequest(
                itemId = item.id,
                title = item.name,
                year = item.productionYear,
                imageTag = item.imageTags["Primary"].orEmpty(),
            ),
        ).shows.filter { it.itemId.isNotBlank() }
    }

    suspend fun removeMyShow(itemId: String) {
        if (ServerConfig.isGateway) requireGateway().removeMyShow(itemId)
    }

    suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse =
        if (ServerConfig.isGateway) {
            requireGateway().notifications().let { response ->
                response.copy(notifications = response.notifications.filter { it.id > 0 })
            }
        } else {
            com.ponzischeme89.memby.data.model.NotificationsResponse()
        }

    suspend fun setNotificationPreferences(
        value: com.ponzischeme89.memby.data.model.NotificationPreferences,
    ): com.ponzischeme89.memby.data.model.NotificationsResponse =
        requireGateway().setNotificationPreferences(value).let { response ->
            response.copy(notifications = response.notifications.filter { it.id > 0 })
        }

    suspend fun markNotificationRead(id: Long) {
        if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read")
    }

    suspend fun dismissNotification(id: Long) {
        if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
    }

    fun myShowImageUrl(itemId: String, imageTag: String, maxWidth: Int = 320): String? =
        imageTag.takeIf(String::isNotBlank)?.let {
            imageUrl(itemId, "Primary", it, maxWidth)
        }

    /** Live gateway state. Unlike content routes, this remains available in maintenance. */
    suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()

    suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()

    /**
     * This viewer's colour scheme, and the ones the operator lets them choose between.
     *
     * Gateway only, and deliberately so rather than falling through to a direct-path
     * equivalent: with no gateway there is nobody who decides themes, and inventing a
     * client-side rule would mean a household's palette changing depending on whether the
     * container happens to be up. The direct path keeps the default palette, which is what
     * the app looked like before this existed.
     */
    suspend fun theme(): com.ponzischeme89.memby.data.model.GatewayThemeDocument =
        requireGateway().theme()

    /** This viewer's server-held settings. */
    suspend fun userPreferences(): com.ponzischeme89.memby.data.model.GatewayPreferences =
        requireGateway().preferences()

    /**
     * Writes them back, at the revision this TV believes it is editing.
     *
     * A 409 is not an error here and not a reason to retry: somebody else changed the
     * document, and on this endpoint that is nearly always the operator pushing settings
     * from the admin console. Their copy comes back in the conflict body, so it is
     * returned as an ordinary result and the caller adopts it. Retrying would undo them.
     */
    suspend fun saveUserPreferences(
        revision: Long,
        preferences: kotlinx.serialization.json.JsonObject,
    ): com.ponzischeme89.memby.data.model.GatewayPreferences = try {
        requireGateway().savePreferences(
            com.ponzischeme89.memby.data.model.GatewayPreferencesRequest(revision, preferences),
        )
    } catch (conflict: HttpException) {
        if (conflict.code() != 409) throw conflict
        parsePreferencesConflict(conflict) ?: throw conflict
    }

    /**
     * Whether Emby answers, on the direct path where there is no gateway to ask.
     *
     * `System/Info/Public` needs no credentials and returns a few bytes, which is what
     * makes it usable as a repeated probe — anything under `Users/{id}` would fail for a
     * stale token and be reported as an outage that is really a sign-in problem.
     */
    suspend fun pingEmby(): Boolean =
        runCatching { requireApi().systemInfoPublic().close() }.isSuccess

    /** Full item metadata, requested only after focus settles on an item. */
    suspend fun getItemDetails(itemId: String): BaseItem {
        if (ServerConfig.isGateway) return requireGateway().item(itemId)
        val userId = snapshot.userId ?: error("Not connected")
        return requireApi().getItem(
            userId = userId,
            itemId = itemId,
            fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName",
        )
    }

    // Bounded, access-ordered and synchronised rather than a plain concurrent map. These
    // are process-lifetime caches on a device that is commonly left on for weeks, and an
    // unbounded one holds an entry for every person, filmography and title the household
    // has ever put focus on — which on a large library is the library.
    private val personCache = boundedCache<BaseItem>(PERSON_CACHE_SIZE)
    private val filmographyCache = boundedCache<List<BaseItem>>(FILMOGRAPHY_CACHE_SIZE)

    /** Biography and life dates for a cast member. Emby models a person as an item. */
    suspend fun getPersonDetails(personId: String): BaseItem {
        require(personId.isNotBlank()) { "Person id is required" }
        val cacheKey = "${snapshot.userId.orEmpty()}:$personId"
        personCache[cacheKey]?.let { return it }
        val person = if (ServerConfig.isGateway) {
            requireGateway().person(personId)
        } else {
            val userId = snapshot.userId ?: error("Not connected")
            requireApi().getItem(
                userId = userId,
                itemId = personId,
                fields = "Overview,Genres,PrimaryImageAspectRatio",
            )
        }
        personCache[cacheKey] = person
        return person
    }

    /** Films and series associated with a person, newest first. */
    suspend fun getPersonFilmography(personId: String): List<BaseItem> {
        require(personId.isNotBlank()) { "Person id is required" }
        val cacheKey = "${snapshot.userId.orEmpty()}:$personId"
        filmographyCache[cacheKey]?.let { return it }
        val items = if (ServerConfig.isGateway) {
            requireGateway().personFilmography(personId).items
        } else {
            val userId = snapshot.userId ?: error("Not connected")
            requireApi().getItems(
                userId,
                mapOf(
                    "PersonIds" to personId,
                    "IncludeItemTypes" to "Movie,Series",
                    "Recursive" to "true",
                    "SortBy" to "ProductionYear,SortName",
                    "SortOrder" to "Descending",
                    "Limit" to "60",
                    "Fields" to "Overview,ProductionYear,PrimaryImageAspectRatio",
                    "EnableImages" to "true",
                    "EnableImageTypes" to "Primary",
                    "ImageTypeLimit" to "1",
                    "EnableUserData" to "true",
                ),
            ).items
        }
        filmographyCache[cacheKey] = items
        return items
    }

    private val ratingsCache =
        boundedCache<List<com.ponzischeme89.memby.data.model.MediaRating>>(RATINGS_CACHE_SIZE)

    /** Optional ratings that never block essential metadata. The gateway owns a durable
     * cache; this process cache makes repeated focus/detail visits free on the TV. */
    suspend fun getRatings(item: BaseItem): List<com.ponzischeme89.memby.data.model.MediaRating> {
        // Rows and detail payloads now carry whatever the gateway already had stored, so
        // the common case costs no request at all. Seed the process cache with it: the
        // same title reached from search or a related rail then needs nothing either.
        item.membyRatings.takeIf { it.isNotEmpty() }?.let { carried ->
            ratingsCache[item.id] = carried
            return carried
        }
        ratingsCache[item.id]?.let { return it }
        // Emby's own community rating is deliberately not a fallback. It arrives with no
        // provider behind it, so standing it in for a real one meant a card claiming a
        // TMDb score TMDb had never been asked for — and the strip exists to say where a
        // number came from. A title MDBList cannot answer for shows no strip at all.
        val result = if (ServerConfig.isGateway && item.id.isNotBlank()) {
            runCatching { requireGateway().movieRatings(item.id).ratings }.getOrDefault(emptyList())
        } else emptyList()
        // Do not make an empty result permanent: the gateway fills its cache behind the
        // viewer, so a title it could not decorate now can answer a moment later.
        if (result.isNotEmpty()) ratingsCache[item.id] = result
        return result
    }

    @Deprecated("Use getRatings(BaseItem)")
    suspend fun getMovieRatings(itemId: String): List<com.ponzischeme89.memby.data.model.MediaRating> {
        val item = runCatching { getItemDetails(itemId) }.getOrNull() ?: return emptyList()
        return getRatings(item)
    }

    /** Whether this episode closes its season; failures are non-fatal playback metadata. */
    suspend fun seasonFinale(itemId: String): com.ponzischeme89.memby.data.model.GatewaySeasonFinale {
        if (itemId.isBlank()) return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
        if (ServerConfig.isGateway) return requireGateway().seasonFinale(itemId)

        // Direct mode has no Sonarr catalogue. Use Emby's full episode list as the best
        // available fallback; gateway mode remains authoritative because it sees future
        // episodes and cannot confuse the newest download with a finale.
        val current = getItemDetails(itemId)
        val season = current.parentIndexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
        val episode = current.indexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
        val seriesId = current.seriesId ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
        val lastEpisode = getSeriesEpisodes(seriesId)
            .filter { it.parentIndexNumber == season }
            .mapNotNull(BaseItem::indexNumber)
            .maxOrNull()
        if (season <= 0 || lastEpisode == null || episode != lastEpisode) {
            return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
        }
        return com.ponzischeme89.memby.data.model.GatewaySeasonFinale(
            seasonFinale = true,
            seriesName = current.seriesName.orEmpty(),
            seasonNumber = season,
            episodeNumber = episode,
        )
    }

    /**
     * The detail page's "why you might like this" strip and its related carousel.
     *
     * Dual-path like everything else, but the two paths differ in what they can say: the
     * gateway runs the recommendation engine and returns reasons drawn from this user's
     * own history, while direct mode has only Emby's similarity ranking and therefore no
     * reasons at all. An empty result is normal, never an error — a detail page that
     * cannot explain itself still has to open.
     *
     * Two things keep this from being noisy. It is **single-flighted on the repository's
     * own scope**, because the caller is usually [HomeViewModel.focusItem] warming the
     * page while the card is focused: that job is cancelled the moment the D-pad moves
     * on, and a request cancelled at the socket is one the gateway logs as a failure and
     * one nobody keeps the answer of — so the shared request outlives the caller that
     * started it, and opening Details a moment later awaits it rather than asking again.
     * And **only a real answer is cached**; a failure is not, or one bad minute would
     * leave the page unable to explain itself for the next ten.
     */
    suspend fun getRelated(item: BaseItem, limit: Int = RELATED_LIMIT): RelatedContent {
        if (item.id.isBlank()) return RelatedContent()
        val now = System.currentTimeMillis()
        val inFlight = relatedMutex.withLock {
            relatedCache[item.id]
                ?.takeIf { it.expiresAtMs > now }
                ?.let { return it.content }
            relatedCache.remove(item.id)
            relatedInFlight[item.id] ?: newRelatedRequest(item, limit)
        }
        return inFlight.await() ?: RelatedContent()
    }

    private fun newRelatedRequest(item: BaseItem, limit: Int): Deferred<RelatedContent?> {
        val request = scope.async(start = CoroutineStart.LAZY) {
            try {
                val loaded = runCatching { loadRelated(item, limit) }.getOrNull()
                    ?: return@async null
                relatedMutex.withLock {
                    relatedCache[item.id] = CachedRelated(
                        content = loaded.copy(items = loaded.items.take(limit)),
                        expiresAtMs = System.currentTimeMillis() + RELATED_CACHE_TTL_MS,
                    )
                    while (relatedCache.size > RELATED_CACHE_SIZE) {
                        relatedCache.entries.iterator().run {
                            next()
                            remove()
                        }
                    }
                    relatedCache.getValue(item.id).content
                }
            } finally {
                relatedMutex.withLock { relatedInFlight.remove(item.id) }
            }
        }
        relatedInFlight[item.id] = request
        request.start()
        return request
    }

    /**
     * The carousel under a detail page, from whichever backend there is.
     *
     * Both paths drop the subject itself and then deduplicate. The gateway's answer is
     * assembled from Emby's similarity list with the imported catalogue's genre
     * neighbours behind it, and the two can name the same title; the carousel is a keyed
     * LazyRow, where a repeat takes the detail page down rather than drawing a poster
     * twice.
     */
    private suspend fun loadRelated(item: BaseItem, limit: Int): RelatedContent {
        if (ServerConfig.isGateway) {
            val response = requireGateway().related(item.id)
            return RelatedContent(
                reasons = response.reasons.filter(String::isNotBlank),
                items = response.items.filter { it.id != item.id }.distinctBy(BaseItem::id),
            )
        }
        val userId = snapshot.userId ?: error("Not connected")
        return RelatedContent(
            items = requireApi().getSimilar(
                itemId = item.id,
                params = mapOf(
                    "UserId" to userId,
                    "Limit" to limit.toString(),
                    "Fields" to "ProductionYear,RunTimeTicks,CommunityRating,PrimaryImageAspectRatio,CollectionName",
                    "EnableUserData" to "true",
                    "EnableImages" to "true",
                    "EnableImageTypes" to "Primary,Backdrop,Logo",
                    "ImageTypeLimit" to "1",
                ),
            ).items.filter { it.id != item.id }.distinctBy(BaseItem::id),
        )
    }

    /**
     * All episodes for one show in a single request. Season switching is then a local
     * list filter, keeping the detail screen immediate after its first load.
     *
     * **Single-flighted on the repository's own scope**, for the reason [getRelated] is:
     * the caller is usually [HomeViewModel.focusItem] warming the page while a card is
     * still focused, and that job is cancelled the moment the D-pad moves on. A request
     * cancelled at the socket is one the gateway logs as a failure and one nobody keeps
     * the answer of — so the shared request outlives the caller that started it, and the
     * detail page opened a moment later awaits it rather than asking for the same
     * thousand episodes again.
     */
    suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
        if (seriesId.isBlank()) return emptyList()
        val now = System.currentTimeMillis()
        val inFlight = seriesEpisodesMutex.withLock {
            seriesEpisodesCache[seriesId]
                ?.takeIf { it.expiresAtMs > now }
                ?.episodes
                ?.let { return it }
            seriesEpisodesCache.remove(seriesId)
            seriesEpisodesInFlight[seriesId] ?: newSeriesEpisodesRequest(seriesId)
        }
        // A failure is the caller's to report, the way it was when this awaited the request
        // directly: the detail page distinguishes "no episodes" from "could not load them".
        return inFlight.await() ?: error("Could not load episodes for $seriesId")
    }

    private fun newSeriesEpisodesRequest(seriesId: String): Deferred<List<BaseItem>?> {
        val request = scope.async(start = CoroutineStart.LAZY) {
            try {
                runCatching { loadSeriesEpisodes(seriesId) }.getOrNull()
            } finally {
                seriesEpisodesMutex.withLock { seriesEpisodesInFlight.remove(seriesId) }
            }
        }
        seriesEpisodesInFlight[seriesId] = request
        request.start()
        return request
    }

    /**
     * Every episode of a series, deduplicated on the way in.
     *
     * The episode list is rendered as a keyed lazy list on the series page and again on an
     * episode's own page, so one id arriving twice — a file imported under two paths, a
     * gateway that concatenated a paged read — is a crash on both rather than a repeated
     * row. It is also the list the season scroller and the pace estimate are derived from,
     * and a duplicate would quietly overstate both.
     */
    private suspend fun loadSeriesEpisodes(seriesId: String): List<BaseItem> {
        val now = System.currentTimeMillis()
        val loaded = (if (ServerConfig.isGateway) {
            requireGateway().seriesEpisodes(seriesId).items
        } else {
            val userId = snapshot.userId ?: error("Not connected")
            requireApi().getEpisodes(
                seriesId,
                mapOf(
                    "UserId" to userId,
                    "Fields" to "Overview,RunTimeTicks,SeriesName,PremiereDate,PrimaryImageAspectRatio",
                    "EnableUserData" to "true",
                    "EnableImages" to "true",
                    "EnableImageTypes" to "Primary,Thumb,Backdrop",
                    "ImageTypeLimit" to "1",
                    "Limit" to "1000",
                ),
            ).items
        }).filter { it.id.isNotBlank() }.distinctBy(BaseItem::id)
        seriesEpisodesMutex.withLock {
            seriesEpisodesCache[seriesId] = CachedSeriesEpisodes(
                episodes = loaded,
                expiresAtMs = now + SERIES_EPISODE_CACHE_TTL_MS,
            )
            while (seriesEpisodesCache.size > SERIES_EPISODE_CACHE_SIZE) {
                seriesEpisodesCache.entries.iterator().run {
                    next()
                    remove()
                }
            }
        }
        return loaded
    }

    /** Tight list endpoint shape: detail-only fields are never fetched on home. */
    private suspend fun getHomeItems(
        params: Map<String, String>,
        fields: String,
        imageTypes: String,
        includeUserData: Boolean = false,
    ): List<BaseItem> {
        val userId = snapshot.userId ?: error("Not connected")
        return requireApi().getItems(userId, params + mapOf(
            "Fields" to fields,
            "ImageTypeLimit" to "1",
            "EnableImages" to "true",
            "EnableImageTypes" to imageTypes,
            "EnableTotalRecordCount" to "false",
            "EnableUserData" to includeUserData.toString(),
        )).items
    }

    /** Toggles favorite state, returning the new value. */
    suspend fun toggleFavorite(item: BaseItem): Boolean =
        setFavorite(item.id, !item.isFavorite)

    /**
     * Sets favorite state to an explicit [favorite] value and returns the server's
     * resulting state. Safer than [toggleFavorite] for optimistic UI, which may
     * flip faster than the source item's cached [BaseItem.isFavorite] updates.
     */
    suspend fun setFavorite(itemId: String, favorite: Boolean): Boolean {
        if (ServerConfig.isGateway) {
            return requireGateway().setFavorite(itemId, GatewayFlagRequest(favorite)).isFavorite
        }
        val userId = snapshot.userId ?: error("Not connected")
        val api = requireApi()
        val result = if (favorite) {
            api.addFavorite(userId, itemId)
        } else {
            api.removeFavorite(userId, itemId)
        }
        return result.isFavorite
    }

    /** Sets watched state explicitly and returns the value confirmed by Emby. */
    suspend fun setPlayed(itemId: String, played: Boolean): Boolean {
        if (ServerConfig.isGateway) {
            return requireGateway().setPlayed(itemId, GatewayFlagRequest(played)).played
        }
        val userId = snapshot.userId ?: error("Not connected")
        val api = requireApi()
        val result = if (played) {
            api.markPlayed(userId, itemId)
        } else {
            api.markUnplayed(userId, itemId)
        }
        return result.played
    }

    /** Removes a title from Continue Watching without changing its watched state. */
    suspend fun removeFromContinueWatching(itemId: String) {
        if (ServerConfig.isGateway) {
            requireGateway().hideFromResume(itemId)
            return
        }
        val userId = snapshot.userId ?: error("Not connected")
        requireApi().hideFromResume(userId, itemId)
    }

    /**
     * Emby's first local trailer for an item, when one is available.
     *
     * Cached, **including the negative answer**, and single-flighted on the repository's
     * scope like [getRelated]. Most of a library has no local trailer, so before this every
     * detail page opened with a request the gateway answered 404 to — repeated on walking
     * Back and reopening, and again for every step of the "More like this" trail. Whether a
     * title has a trailer only changes when its media does, so the entry lives as long as
     * the process.
     */
    suspend fun getLocalTrailer(itemId: String): BaseItem? {
        if (itemId.isBlank()) return null
        val inFlight = trailerMutex.withLock {
            trailerCache[itemId]?.let { return it.value }
            trailerInFlight[itemId] ?: newTrailerRequest(itemId)
        }
        // A failure is not cached — one bad minute must not leave a title trailerless for
        // the rest of the session — and it is not surfaced either: a missing trailer button
        // is the same outcome the 404 already produces.
        return inFlight.await()?.value
    }

    private fun newTrailerRequest(itemId: String): Deferred<CachedTrailer?> {
        val request = scope.async(start = CoroutineStart.LAZY) {
            try {
                val loaded = runCatching { loadLocalTrailer(itemId) }.getOrNull()
                    ?: return@async null
                trailerMutex.withLock {
                    trailerCache[itemId] = loaded
                    while (trailerCache.size > TRAILER_CACHE_SIZE) {
                        trailerCache.entries.iterator().run {
                            next()
                            remove()
                        }
                    }
                    loaded
                }
            } finally {
                trailerMutex.withLock { trailerInFlight.remove(itemId) }
            }
        }
        trailerInFlight[itemId] = request
        request.start()
        return request
    }

    private suspend fun loadLocalTrailer(itemId: String): CachedTrailer {
        if (ServerConfig.isGateway) {
            // The gateway answers 404 when an item has no trailer, which is a normal
            // outcome here rather than an error worth surfacing.
            val trailer = runCatching { requireGateway().trailer(itemId) }
                .getOrElse { if (it is HttpException && it.code() == 404) null else throw it }
            return CachedTrailer(trailer)
        }
        val userId = snapshot.userId ?: error("Not connected")
        return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull())
    }

    /**
     * Uploads a batch of row-engagement events. Silent on the direct path (nothing is
     * listening) and silent on failure — telemetry must never surface on a TV.
     */
    fun reportRowEvents(events: List<GatewayRowEvent>) {
        if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return
        scope.launch {
            runCatching { requireGateway().reportRowEvents(GatewayRowEvents(events)) }
        }
    }

    /** Uploads authenticated per-user journey events without affecting the UI. */
    fun reportJourneyEvents(events: List<GatewayJourneyEvent>) {
        if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return
        // The server also verifies this guard. It prevents an old profile's buffered
        // events being attributed to the new profile during the handover frame.
        val currentUser = snapshot.userId ?: return
        val matching = events.filter { it.userId == currentUser }
        if (matching.isEmpty()) return
        scope.launch { runCatching { requireGateway().reportJourneyEvents(GatewayJourneyEvents(matching)) } }
    }

    /**
     * Flushes both analytics streams before a profile token is replaced. Unlike the
     * periodic fire-and-forget path, this completes under the outgoing user's bearer token.
     */
    suspend fun reportAnalyticsBeforeProfileSwitch(
        rowEvents: List<GatewayRowEvent>,
        journeyEvents: List<GatewayJourneyEvent>,
    ) {
        if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return
        val currentUser = snapshot.userId ?: return
        val matchingJourneys = journeyEvents.filter { it.userId == currentUser }
        runCatching {
            if (rowEvents.isNotEmpty()) requireGateway().reportRowEvents(GatewayRowEvents(rowEvents))
            if (matchingJourneys.isNotEmpty()) {
                requireGateway().reportJourneyEvents(GatewayJourneyEvents(matchingJourneys))
            }
        }
    }

    /**
     * Optional decoration for the player pre-roll. Failure or direct-to-Emby mode returns
     * an empty schedule immediately; playback never depends on this request.
     */
    suspend fun prerollSchedule(): GatewayPrerollSchedule {
        if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) {
            return GatewayPrerollSchedule()
        }
        return runCatching { requireGateway().prerollSchedule() }
            .getOrDefault(GatewayPrerollSchedule())
    }

    fun prerollArtworkUrl(itemId: String, imageType: String, maxWidth: Int): String? {
        if (!ServerConfig.isGateway || itemId.isBlank() || imageType.isBlank()) return null
        return imageUrl(
            itemId = itemId,
            imageType = imageType,
            tag = "sonarr",
            maxWidth = maxWidth,
        )
    }

    /** Backdrop rotation interval, clamped to a sane range. */
    fun rotationIntervalMillis(): Long =
        snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L

    /** Whether a usable session is currently persisted. */
    fun isSignedIn(): Boolean = snapshot.isSignedIn

    /**
     * Resolves an item to something ExoPlayer can stream. Movies play directly;
     * for a series we play the next-up episode (falling back to the first one).
     */
    suspend fun resolvePlayable(item: BaseItem): Playable {
        require(item.membyPlayable) { "This item is informational and cannot be played" }
        val playback = playbackRequest(item)
        val now = System.currentTimeMillis()
        val (cached, request) = playableMutex.withLock {
            val ready = playableCache[item.id]?.takeIf { it.expiresAtMs > now }?.playable
            if (ready != null) {
                ready to null
            } else {
                playableCache.remove(item.id)
                null to (playableInFlight[item.id] ?: newPlayableRequest(playback))
            }
        }
        return cached ?: requireNotNull(request).await()
    }

    /**
     * The launch-time description of [item] — what the launcher already knows, in the form
     * the player and [resolvePlayableForLaunch] both take. Built here because the artwork
     * and episode-code helpers it needs are the repository's.
     */
    fun playbackRequest(item: BaseItem): PlaybackRequest = PlaybackRequest(
        itemId = item.id,
        itemType = item.type,
        title = item.name,
        isSeries = item.isSeries,
        resumePositionMs = item.resumePositionMs,
        logoUrl = logoUrl(item),
        overview = item.overview,
        episodeCode = episodeCode(item),
        runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
    )

    /**
     * Returns a prefetched stream if one is sitting ready for [request], without suspending
     * and without starting a request. This is what lets the launcher tell instantly whether
     * pressing Play can hand the player a URL or must let it resolve one for itself, so
     * that decision never costs a frame of its own.
     */
    fun readyPlayableForLaunch(request: PlaybackRequest): Playable? {
        // tryLock rather than a blocking wait, and failing to take it is a legitimate
        // answer: the lock is held exactly when a resolution is in flight, and the caller's
        // fallback — letting the player await it — is what should happen then anyway.
        if (!playableMutex.tryLock()) return null
        try {
            val entry = playableCache[request.itemId] ?: return null
            if (!isFreshPlayablePrefetch(entry.resolvedAtMs, System.currentTimeMillis())) return null
            // Consumed: negotiated session state must never be handed out twice.
            playableCache.remove(request.itemId)
            return entry.playable.copy(
                resumePositionMs = launchResumePositionMs(
                    resolvedPositionMs = entry.playable.resumePositionMs,
                    requestedPositionMs = request.resumePositionMs,
                ),
            )
        } finally {
            playableMutex.unlock()
        }
    }

    /**
     * Resolves playback for an actual Play action. A prefetched PlaybackInfo response owns
     * negotiated session state, so it is safe only for the brief focus-to-click interval
     * it was created to cover. Consume that result once; an older or already-consumed
     * prefetch is replaced with a fresh server request.
     */
    suspend fun resolvePlayableForLaunch(item: BaseItem): Playable {
        require(item.membyPlayable) { "This item is informational and cannot be played" }
        return resolvePlayableForLaunch(playbackRequest(item))
    }

    suspend fun resolvePlayableForLaunch(request: PlaybackRequest): Playable {
        require(request.itemId.isNotBlank()) { "A media item is required to start playback" }
        val now = System.currentTimeMillis()
        val (cached, inFlight) = playableMutex.withLock {
            val entry = playableCache.remove(request.itemId)
            val ready = entry
                ?.takeIf { isFreshPlayablePrefetch(it.resolvedAtMs, now) }
                ?.playable
            ready to if (ready == null) playableInFlight[request.itemId] else null
        }
        if (cached != null) {
            return cached.copy(
                resumePositionMs = launchResumePositionMs(
                    resolvedPositionMs = cached.resumePositionMs,
                    requestedPositionMs = request.resumePositionMs,
                ),
            )
        }
        if (inFlight != null) {
            val resolved = inFlight.await()
            return resolved.copy(
                resumePositionMs = launchResumePositionMs(
                    resolvedPositionMs = resolved.resumePositionMs,
                    requestedPositionMs = request.resumePositionMs,
                ),
            ).also {
                playableMutex.withLock { playableCache.remove(request.itemId) }
            }
        }
        return resolvePlayableUncached(request)
    }

    /** Resolves the likely stream after focus settles, without opening or buffering it. */
    suspend fun prefetchPlayable(item: BaseItem) {
        if (!item.membyPlayable) return
        resolvePlayable(item)
    }

    /**
     * Resolves a new URL for an item that was already selected for playback. This bypasses
     * the launch cache: recovery must not hand the player the same potentially stale URL.
     *
     * Passing no type hint makes the gateway ask Emby for the authoritative item. That is
     * one extra request only on recovery, and avoids accidentally treating a movie as an
     * episode (or resolving a series a second time).
     */
    suspend fun refreshPlayableStream(
        itemId: String,
        title: String,
        resumePositionMs: Long,
        forceTranscode: Boolean = false,
    ): Playable {
        require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
        if (!ServerConfig.isGateway) {
            val discovery = directPlayback(itemId, resumePositionMs, forceTranscode = forceTranscode)
            return Playable(
                itemId = itemId,
                title = title,
                url = discovery.url ?: buildStreamUrl(itemId),
                resumePositionMs = resumePositionMs.coerceAtLeast(0L),
                subtitles = discovery.subtitles,
                subtitlesEnabled = snapshot.subtitlesEnabled,
                selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
                mediaSourceId = discovery.mediaSourceId,
                playSessionId = discovery.playSessionId,
                playMethod = discovery.playMethod,
                trickplayAvailable = true,
                skipIntroAvailable = true,
                endCreditsAvailable = true,
            )
        }

        val playback = requireGateway().playback(
            itemId = itemId,
            itemType = "",
            title = title,
            resumePositionMs = resumePositionMs.coerceAtLeast(0L),
            forceTranscode = forceTranscode,
        )
        return Playable(
            itemId = playback.itemId,
            title = playback.title.ifBlank { title },
            url = playback.url,
            // Recovery follows the local playhead. The server's persisted position may be
            // up to one progress interval behind and would visibly jump the viewer back.
            resumePositionMs = resumePositionMs.coerceAtLeast(0L),
            subtitles = resolveSubtitleUrls(playback.subtitles),
            subtitlesEnabled = playback.subtitlesEnabled,
            selectedSubtitleId = playback.selectedSubtitleId,
            mediaSourceId = playback.mediaSourceId,
            playSessionId = playback.playSessionId,
            playMethod = playback.playMethod,
            subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
            subtitleFixAvailable = playback.subtitleFixAvailable,
            trickplayAvailable = playback.trickplayAvailable,
            skipIntroAvailable = playback.skipIntroAvailable,
            endCreditsAvailable = playback.endCreditsAvailable,
        )
    }

    suspend fun selectEncodedSubtitle(
        session: PlaybackSession,
        subtitleIndex: Int,
        title: String,
        positionMs: Long,
    ): Playable {
        if (ServerConfig.isGateway) {
            val playback = requireGateway().playback(
                itemId = session.itemId,
                itemType = "",
                title = title,
                resumePositionMs = positionMs.coerceAtLeast(0L),
                subtitleIndex = subtitleIndex,
            )
            return Playable(
                itemId = playback.itemId,
                title = playback.title.ifBlank { title },
                url = playback.url,
                resumePositionMs = positionMs.coerceAtLeast(0L),
                subtitles = resolveSubtitleUrls(playback.subtitles),
                subtitlesEnabled = playback.subtitlesEnabled,
                selectedSubtitleId = playback.selectedSubtitleId,
                mediaSourceId = playback.mediaSourceId,
                playSessionId = playback.playSessionId,
                playMethod = playback.playMethod,
                overview = playback.overview.ifBlank { null },
                episodeCode = playback.episodeCode.ifBlank { null },
                runtimeMs = playback.runtimeMs,
                subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
                subtitleFixAvailable = playback.subtitleFixAvailable,
                trickplayAvailable = playback.trickplayAvailable,
                skipIntroAvailable = playback.skipIntroAvailable,
                endCreditsAvailable = playback.endCreditsAvailable,
            )
        }
        val discovery = directPlayback(
            itemId = session.itemId,
            positionMs = positionMs,
            subtitleStreamIndex = subtitleIndex,
            currentPlaySessionId = session.playSessionId,
        )
        return Playable(
            itemId = session.itemId,
            title = title,
            url = discovery.url ?: buildStreamUrl(session.itemId),
            resumePositionMs = positionMs.coerceAtLeast(0L),
            subtitles = discovery.subtitles,
            // The viewer just chose this track by hand and Emby is burning it into the
            // stream, so there is no text track left for the player to select.
            subtitlesEnabled = true,
            selectedSubtitleId = subtitleIndex.toString(),
            mediaSourceId = discovery.mediaSourceId,
            playSessionId = discovery.playSessionId,
            playMethod = discovery.playMethod,
            trickplayAvailable = true,
            skipIntroAvailable = true,
            endCreditsAvailable = true,
        )
    }

    private fun newPlayableRequest(item: PlaybackRequest): Deferred<Playable> {
        val request = scope.async(start = CoroutineStart.LAZY) {
            try {
                resolvePlayableUncached(item).also { playable ->
                    playableMutex.withLock {
                        playableCache[item.itemId] = CachedPlayable(
                            playable = playable,
                            resolvedAtMs = System.currentTimeMillis(),
                            expiresAtMs = System.currentTimeMillis() + PLAYABLE_CACHE_TTL_MS,
                        )
                        while (playableCache.size > PLAYABLE_CACHE_SIZE) {
                            playableCache.entries.iterator().run {
                                next()
                                remove()
                            }
                        }
                    }
                }
            } finally {
                playableMutex.withLock { playableInFlight.remove(item.itemId) }
            }
        }
        playableInFlight[item.itemId] = request
        request.start()
        return request
    }

    private suspend fun resolvePlayableUncached(item: PlaybackRequest): Playable {
        if (ServerConfig.isGateway) {
            // Episode selection for a series is the gateway's job now.
            val playback = requireGateway().playback(
                itemId = item.itemId,
                itemType = item.itemType,
                title = item.title,
                resumePositionMs = item.resumePositionMs,
            )
            return Playable(
                itemId = playback.itemId,
                title = playback.title.ifBlank { item.title },
                url = playback.url,
                resumePositionMs = playback.resumePositionMs,
                logoUrl = item.logoUrl,
                subtitles = resolveSubtitleUrls(playback.subtitles),
                subtitlesEnabled = playback.subtitlesEnabled,
                selectedSubtitleId = playback.selectedSubtitleId,
                mediaSourceId = playback.mediaSourceId,
                playSessionId = playback.playSessionId,
                playMethod = playback.playMethod,
                overview = playback.overview.ifBlank { item.overview },
                episodeCode = playback.episodeCode.ifBlank { item.episodeCode },
                runtimeMs = playback.runtimeMs.takeIf { it > 0L } ?: item.runtimeMs,
                prerollEnabled = playback.prerollEnabled,
                prerollDurationMs = playback.prerollDurationMs,
                subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
                subtitleFixAvailable = playback.subtitleFixAvailable,
                trickplayAvailable = playback.trickplayAvailable,
                skipIntroAvailable = playback.skipIntroAvailable,
                endCreditsAvailable = playback.endCreditsAvailable,
            )
        }
        if (item.isSeries) {
            val userId = snapshot.userId ?: error("Not connected")
            val episode = firstNextUpEpisode(userId, item.itemId) ?: firstEpisode(userId, item.itemId)
            requireNotNull(episode) { "No episodes found for ${item.title}" }
            val title = buildString {
                append(item.title)
                episode.name.takeIf { it.isNotBlank() }?.let { append(" – $it") }
            }
            val discovery = directPlayback(episode.id, episode.resumePositionMs)
            return Playable(
                itemId = episode.id,
                title = title,
                url = discovery.url ?: buildStreamUrl(episode.id),
                resumePositionMs = episode.resumePositionMs,
                logoUrl = item.logoUrl,
                subtitles = discovery.subtitles,
                subtitlesEnabled = snapshot.subtitlesEnabled,
                selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
                mediaSourceId = discovery.mediaSourceId,
                playSessionId = discovery.playSessionId,
                playMethod = discovery.playMethod,
                trickplayAvailable = true,
                skipIntroAvailable = true,
                endCreditsAvailable = true,
                overview = episode.overview,
                episodeCode = episodeCode(episode),
                runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
            )
        }
        val discovery = directPlayback(item.itemId, item.resumePositionMs)
        return Playable(
            itemId = item.itemId,
            title = item.title,
            url = discovery.url ?: buildStreamUrl(item.itemId),
            resumePositionMs = item.resumePositionMs,
            logoUrl = item.logoUrl,
            subtitles = discovery.subtitles,
            subtitlesEnabled = snapshot.subtitlesEnabled,
            selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
            mediaSourceId = discovery.mediaSourceId,
            playSessionId = discovery.playSessionId,
            playMethod = discovery.playMethod,
            trickplayAvailable = true,
            skipIntroAvailable = true,
            endCreditsAvailable = true,
            overview = item.overview,
            episodeCode = item.episodeCode,
            runtimeMs = item.runtimeMs,
        )
    }

    /** Drop negotiated playback state when a catalogue refresh can change episode selection. */
    suspend fun invalidatePlaybackPrefetch() = clearPlayableCache()

    private suspend fun clearPlayableCache() {
        playableMutex.withLock {
            playableCache.clear()
            playableInFlight.values.forEach { it.cancel() }
            playableInFlight.clear()
        }
    }

    private suspend fun clearSeriesEpisodeCache() {
        seriesEpisodesMutex.withLock {
            // Episodes carry this viewer's watched and resume state, so the same reasoning
            // as below applies: another profile must not inherit them, and a request
            // already in the air was made with the outgoing profile's session.
            seriesEpisodesCache.clear()
            seriesEpisodesInFlight.values.forEach { it.cancel() }
            seriesEpisodesInFlight.clear()
        }
        relatedMutex.withLock {
            // Reasons are personal: another profile must never inherit this one's, and a
            // request already in the air was made with the outgoing profile's session.
            relatedCache.clear()
            relatedInFlight.values.forEach { it.cancel() }
            relatedInFlight.clear()
        }
        trailerMutex.withLock {
            // Not personal — whether a title has a trailer is a property of the library —
            // but the request in flight carries the outgoing session, and the next profile
            // may be signed into a different server entirely.
            trailerCache.clear()
            trailerInFlight.values.forEach { it.cancel() }
            trailerInFlight.clear()
        }
    }

    suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
        if (ServerConfig.isGateway) {
            requireGateway().report("started", session.gatewayReport(positionMs, false, null))
            return
        }
        requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
    }

    suspend fun reportPlaybackProgress(
        session: PlaybackSession,
        positionMs: Long,
        isPaused: Boolean,
        eventName: String,
        durationMs: Long = 0L,
    ): String? {
        if (ServerConfig.isGateway) {
            return requireGateway().report(
                "progress",
                session.gatewayReport(positionMs, isPaused, eventName, durationMs),
            ).autoFollowedShowTitle.takeIf(String::isNotBlank)
        }
        requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
        return null
    }

    suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
        try {
            if (ServerConfig.isGateway) {
                // Stopping is also what drops the gateway's cached rows for this user,
                // so Continue Watching reflects the new position on the next home load.
                requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
            } else {
                requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
            }
        } finally {
            clearPlayableCache()
            // Episode progress and watched badges may have changed during playback.
            clearSeriesEpisodeCache()
            _playbackStops.tryEmit(session.itemId)
        }
    }

    fun enqueuePlaybackStopped(session: PlaybackSession, positionMs: Long) {
        scope.launch {
            runCatching { reportPlaybackStopped(session, positionMs) }
        }
    }

    /**
     * The episode after [itemId], or null when nothing follows it — a movie, a series
     * finale, or simply a server that would not answer. "No next episode" is an ordinary
     * outcome here, so failures are swallowed: the player just shows no banner.
     */
    suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? {
        if (itemId.isBlank()) return null
        return runCatching {
            if (ServerConfig.isGateway) gatewayNextEpisode(itemId, seriesId)
            else directNextEpisode(itemId, seriesId)
        }.getOrNull()
    }

    /**
     * Where this title's opening titles sit, or null when it has none.
     *
     * Dual-path, and the two halves read the same thing from the same place: Emby's chapter
     * markers. What differs is who does the reading — in gateway mode the television holds
     * no Emby credential, so the gateway looks them up and hands back the segment; on the
     * direct path the item is fetched here with `Fields=Chapters` and
     * [introSegmentFrom] applies the rule.
     *
     * An episode with no markers is the ordinary case, not a failure, and so is a server
     * that will not answer: both come back as null and the player simply never offers the
     * button. Nothing about this is on the path of a Play press.
     */
    suspend fun introSegment(itemId: String): IntroSegment? = chapterMarkers(itemId).intro

    /**
     * Where this title's closing credits begin, or null when it has none.
     *
     * Reads the same cached lookup [introSegment] does, which is the whole reason the credits
     * pane costs nothing: the markers are two entries of one chapter list, so the second
     * feature to want them finds them already in hand. It answers only where the marker is —
     * whether it is worth acting on depends on the file's duration, which only the player
     * knows, and is [creditsWorthShowing]'s question.
     */
    suspend fun creditsStartMs(itemId: String): Long? = chapterMarkers(itemId).creditsStartMs

    /**
     * One reading of an item's chapter markers, remembered.
     *
     * Both features read this rather than fetching their own copy. On the direct path that
     * matters most: two lookups would be two `Fields=Chapters` requests to Emby for one
     * response, made at the moment the decoder wants the connection pool.
     */
    private suspend fun chapterMarkers(itemId: String): ChapterMarkers {
        if (itemId.isBlank()) return ChapterMarkers()
        introCache[itemId]?.let { return it.value }
        val resolved = runCatching {
            if (ServerConfig.isGateway) gatewayMarkers(itemId) else directMarkers(itemId)
        }.getOrNull() ?: ChapterMarkers()
        // An empty reading is cached too. Most of a library has no intro markers — every
        // special, every episode Emby has not analysed yet — and without this the same no
        // would be fetched again on each playback and each auto-advance.
        introCache[itemId] = CachedIntro(resolved)
        while (introCache.size > INTRO_CACHE_SIZE) {
            introCache.entries.iterator().run {
                next()
                remove()
            }
        }
        return resolved
    }

    private suspend fun gatewayMarkers(itemId: String): ChapterMarkers {
        val markers = requireGateway().intro(itemId)
        return ChapterMarkers(
            intro = if (markers.available && markers.endMs > markers.startMs) {
                IntroSegment(startMs = markers.startMs, endMs = markers.endMs)
            } else {
                null
            },
            creditsStartMs = markers.creditsStartMs.takeIf { markers.creditsAvailable && it > 0L },
        )
    }

    private suspend fun directMarkers(itemId: String): ChapterMarkers {
        val userId = snapshot.userId ?: return ChapterMarkers()
        // Chapters and nothing else. This is the one query in the app that asks for them,
        // and adding them to a shared Fields list would put a couple of dozen entries per
        // item into every row response to render nothing.
        val item = requireApi().getItem(userId, itemId, "Chapters")
        return ChapterMarkers(
            intro = introSegmentFrom(item.chapters),
            // The runtime is what tells a chapter named "Credits" from one named "Opening
            // Credits". It is a default field on this response, so it costs nothing to use.
            creditsStartMs = creditsStartFrom(
                item.chapters,
                item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
            ),
        )
    }

    /**
     * How this title's seek previews are laid out, or null when it has none.
     *
     * Dual-path like everything else, but the two halves differ in *where the reading
     * happens* rather than in what is read. In gateway mode the television holds no Emby
     * credential, so the gateway reads the file and serves a frame at a time; on the
     * direct path there is nobody to ask, so the index is read here — one ranged request
     * off the front of Emby's file, which is where the format puts it.
     *
     * A title with no previews is the ordinary case, not a failure, and so is a server
     * that will not answer: both come back as null and the seek indicator keeps the
     * wordless form it has always had.
     */
    suspend fun trickplay(itemId: String): Trickplay? {
        if (itemId.isBlank()) return null
        trickplayCache[itemId]?.let { return it.value }
        val resolved = runCatching {
            if (ServerConfig.isGateway) gatewayTrickplay(itemId) else directTrickplay(itemId)
        }.getOrNull()
        // A null is cached too. A title whose previews have not been generated is common
        // in a library still catching up, and without this every press of Right on one
        // would be a fresh round trip for the same no.
        trickplayCache[itemId] = CachedTrickplay(resolved)
        while (trickplayCache.size > TRICKPLAY_CACHE_SIZE) {
            trickplayCache.entries.iterator().run {
                next()
                remove()
            }
        }
        return resolved
    }

    /**
     * One thumbnail's JPEG bytes, or null if it could not be fetched.
     *
     * Decoding is left to the caller: this is asked for from a player that already owns a
     * bitmap cache, and handing back bytes keeps the decode on the caller's terms.
     */
    suspend fun trickplayFrame(track: Trickplay, index: Int): ByteArray? {
        if (index < 0 || index >= track.count) return null
        val bif = track.bif
        if (bif != null) {
            val url = track.bifUrl ?: return null
            val range = bif.frame(index) ?: return null
            return TrickplayClient.fetch(url, range)
        }
        val gateway = ServerConfig.gatewayUrl ?: return null
        val token = snapshot.token
        if (token.isNullOrBlank()) return null
        // The ".jpg" carries no meaning to the gateway. It is there for anything
        // downstream that reads a URL rather than a content type.
        val url = "${gateway.trimEnd('/')}/v1/items/${track.itemId}/trickplay/$index.jpg" +
            "?t=${encode(token)}"
        return TrickplayClient.fetch(url)
    }

    private suspend fun gatewayTrickplay(itemId: String): Trickplay? {
        val manifest = requireGateway().trickplay(itemId)
        if (!manifest.available || manifest.count <= 0 || manifest.intervalMs <= 0L) return null
        return Trickplay(
            itemId = itemId,
            intervalMs = manifest.intervalMs,
            count = manifest.count,
            width = manifest.width,
            height = manifest.height,
        )
    }

    private suspend fun directTrickplay(itemId: String): Trickplay? {
        val base = activeServerUrl ?: return null
        val token = snapshot.token
        if (token.isNullOrBlank()) return null
        val url = "${base.trimEnd('/')}/Videos/$itemId/index.bif" +
            "?Width=$TRICKPLAY_WIDTH&api_key=${encode(token)}"

        val head = TrickplayClient.fetch(url, 0L until TRICKPLAY_INDEX_WINDOW) ?: return null
        val index = when (val parsed = parseBifIndex(head)) {
            is BifParse.Parsed -> parsed.index
            is BifParse.NotBif -> return null
            // A title long enough that its index runs past the window. The count is known
            // now, so the second read is exact.
            is BifParse.NeedMore -> {
                val full = TrickplayClient.fetch(url, 0L until parsed.bytes.toLong()) ?: return null
                (parseBifIndex(full) as? BifParse.Parsed)?.index ?: return null
            }
        }
        if (index.count <= 0) return null
        return Trickplay(
            itemId = itemId,
            intervalMs = index.intervalMs,
            count = index.count,
            bif = index,
            bifUrl = url,
        )
    }

    /**
     * Ask the gateway for subtitles this title does not have.
     *
     * Gateway-only, and deliberately so: fetching a subtitle means talking to Bazarr, which
     * means holding its API key, and a television is the wrong place for that. With no
     * gateway the player simply never offers the option, which is what
     * [Playable.subtitleDownloadAvailable] already tells it.
     *
     * A failure comes back as an empty result with a sentence saying so rather than as an
     * exception. This is a live query against subtitle providers — slow, and allowed to
     * find nothing — so "it did not work" is an ordinary answer the drop-up has to render
     * either way, and there is nothing useful for a caller to do with a throw.
     */
    suspend fun searchSubtitles(itemId: String, language: String? = null): SubtitleSearch {
        if (itemId.isBlank() || !ServerConfig.isGateway) {
            return SubtitleSearch(emptyList(), "Subtitle downloads are not available.")
        }
        return runCatching {
            val response = requireGateway().searchSubtitles(itemId, language?.takeIf { it.isNotBlank() })
            SubtitleSearch(response.results, response.message)
        }.getOrElse { error -> SubtitleSearch(emptyList(), friendlyEmbyError(error)) }
    }

    /**
     * Fetch one of those subtitles. Success carries the item's tracks re-read from Emby
     * after it was told to look again, so the player can put the new track on without a
     * second round trip; null means it did not arrive.
     */
    suspend fun downloadSubtitle(
        itemId: String,
        candidate: GatewaySubtitleCandidate,
    ): SubtitleDownload? {
        if (itemId.isBlank() || !ServerConfig.isGateway || candidate.token.isBlank()) return null
        return runCatching {
            val response = requireGateway().downloadSubtitle(
                itemId,
                GatewaySubtitleDownloadRequest(candidate),
            )
            SubtitleDownload(
                message = response.message,
                subtitles = resolveSubtitleUrls(response.subtitles),
                selectedSubtitleId = response.selectedSubtitleId,
                mediaSourceId = response.mediaSourceId,
                playSessionId = response.playSessionId,
                url = response.url,
            )
        }.getOrNull()
    }

    /**
     * Check an existing subtitle's timing and keep the corrected copy when it moved.
     *
     * Gateway-only: the direct path has nowhere durable to store the repaired sidecar.
     * Null is a transport failure; a safe refusal to guess is a successful response whose
     * [SubtitleFix.message] explains why nothing changed.
     */
    suspend fun fixSubtitle(itemId: String, subtitleId: String): SubtitleFix? {
        if (itemId.isBlank() || subtitleId.isBlank() || !ServerConfig.isGateway) return null
        return runCatching {
            val response = requireGateway().fixSubtitle(
                itemId,
                GatewaySubtitleFixRequest(subtitleId),
            )
            SubtitleFix(
                subtitleId = response.subtitleId,
                message = response.message,
                changed = response.changed,
                offsetMs = response.offsetMs,
                reference = response.reference,
            )
        }.getOrNull()
    }

    private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
        val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
        return nextEpisodeOf(
            response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles),
            response.subtitlesEnabled, response.selectedSubtitleId,
            response.mediaSourceId, response.playSessionId, response.playMethod,
            response.subtitleDownloadAvailable, response.trickplayAvailable,
            response.skipIntroAvailable, response.endCreditsAvailable,
        )
    }

    private suspend fun directNextEpisode(itemId: String, seriesId: String?): NextEpisode? {
        val userId = snapshot.userId ?: error("Not connected")
        val series = seriesId?.takeIf { it.isNotBlank() }
            ?: getItemDetails(itemId).seriesId
            ?: return null
        // AdjacentTo gives back [previous, current, next] in running order, minus whichever
        // ends do not exist — so the current episode's position identifies the next one.
        val episodes = requireApi().getEpisodes(
            series,
            mapOf(
                "UserId" to userId,
                "AdjacentTo" to itemId,
                "Fields" to "RunTimeTicks,Overview,SeriesName",
                "EnableUserData" to "true",
                "EnableImageTypes" to "Primary,Thumb",
            ),
        ).items
        val current = episodes.indexOfFirst { it.id == itemId }.takeIf { it >= 0 } ?: return null
        val next = episodes.getOrNull(current + 1) ?: return null
        val discovery = directPlayback(next.id, next.resumePositionMs)
        return nextEpisodeOf(
            next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
            snapshot.subtitlesEnabled, selectedSubtitleId(discovery.subtitles),
            discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
            true, true, true, true,
        )
    }

    private fun nextEpisodeOf(
        item: BaseItem,
        url: String,
        resumePositionMs: Long,
        subtitles: List<PlayableSubtitle>,
        subtitlesEnabled: Boolean,
        selectedSubtitleId: String,
        mediaSourceId: String,
        playSessionId: String,
        playMethod: String,
        subtitleDownloadAvailable: Boolean,
        trickplayAvailable: Boolean,
        skipIntroAvailable: Boolean,
        endCreditsAvailable: Boolean,
    ) = NextEpisode(
        itemId = item.id,
        title = item.name,
        seriesName = item.seriesName.orEmpty(),
        episodeCode = item.episodeCode,
        imageUrl = primaryUrl(item, maxWidth = 400),
        logoUrl = logoUrl(item),
        overview = item.overview.orEmpty(),
        runtimeMs = item.runTimeTicks?.div(10_000L) ?: 0L,
        url = url,
        resumePositionMs = resumePositionMs,
        subtitles = subtitles,
        subtitlesEnabled = subtitlesEnabled,
        selectedSubtitleId = selectedSubtitleId,
        mediaSourceId = mediaSourceId,
        playSessionId = playSessionId,
        playMethod = playMethod,
        subtitleDownloadAvailable = subtitleDownloadAvailable,
        trickplayAvailable = trickplayAvailable,
        skipIntroAvailable = skipIntroAvailable,
        endCreditsAvailable = endCreditsAvailable,
    )

    /**
     * The track this viewer's settings choose out of [subtitles], on the direct path.
     *
     * In gateway mode the server answers this - it is the thing that asks Emby what tracks
     * exist, and answering it there is what lets one decision follow a person between
     * televisions. With no gateway there is nobody to ask, so the same rule
     * ([selectSubtitleId]) runs here against the same synced settings, and a viewer gets
     * the same subtitles either way.
     */
    private fun selectedSubtitleId(subtitles: List<PlayableSubtitle>): String =
        selectSubtitleId(
            candidates = subtitles.map(PlayableSubtitle::asCandidate),
            enabled = snapshot.subtitlesEnabled,
            language = snapshot.subtitleLanguage,
        ).orEmpty()

    private suspend fun directPlayback(
        itemId: String,
        positionMs: Long,
        subtitleStreamIndex: Int? = null,
        currentPlaySessionId: String? = null,
        forceTranscode: Boolean = false,
    ): PlaybackDiscovery {
        val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
        val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
        val token = snapshot.token.orEmpty()
        return runCatching {
            val info = requireApi().getPlaybackInfo(
                itemId,
                userId,
                body = PlaybackInfoRequest(
                    id = itemId,
                    userId = userId,
                    startTimeTicks = millisecondsToTicks(positionMs),
                    enableDirectPlay = !forceTranscode,
                    enableDirectStream = !forceTranscode,
                    // A compatibility retry must re-encode the failing video codec.
                    // Leaving stream-copy enabled can return the same HEVC Main 10
                    // elementary stream inside HLS and reproduce the decoder failure.
                    allowVideoStreamCopy = !forceTranscode,
                    subtitleStreamIndex = subtitleStreamIndex,
                    currentPlaySessionId = currentPlaySessionId,
                    // The viewer's own passthrough choice reaches the device profile as
                    // well as the audio sink: a set told to bitstream DTS must also ask
                    // Emby to send it, or the switch turns on a format nothing delivers.
                    deviceProfile = com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
                        capabilities = devicePlaybackCapabilities,
                        audio = deviceAudioCapabilities,
                        passthrough = snapshot.audioPassthroughPreference,
                    ).let { profile ->
                        if (forceTranscode) profile.h264TranscodeFallback() else profile
                    },
                ),
            )
            info.mediaSources.firstOrNull()?.let { source ->
                val delivery = selectPlaybackDelivery(
                    source = source,
                    forceTranscode = forceTranscode || subtitleStreamIndex != null,
                )
                PlaybackDiscovery(
                    subtitles = subtitleTracks(
                        streams = source.mediaStreams,
                        serverUrl = serverUrl,
                        token = token,
                        itemId = itemId,
                        mediaSourceId = source.id,
                    ),
                    mediaSourceId = source.id.ifBlank { itemId },
                    playSessionId = info.playSessionId,
                    playMethod = delivery.playMethod,
                    url = delivery.url?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
                )
            } ?: PlaybackDiscovery(mediaSourceId = itemId)
        }.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) }
    }

    private suspend fun firstNextUpEpisode(userId: String, seriesId: String): BaseItem? =
        runCatching {
            requireApi().getNextUp(
                mapOf(
                    "UserId" to userId,
                    "SeriesId" to seriesId,
                    "Limit" to "1",
                    "Fields" to "RunTimeTicks",
                    "EnableUserData" to "true",
                ),
            ).items.firstOrNull()
        }.getOrNull()

    private suspend fun firstEpisode(userId: String, seriesId: String): BaseItem? =
        runCatching {
            requireApi().getEpisodes(
                seriesId,
                mapOf(
                    "UserId" to userId,
                    "Limit" to "1",
                    "Fields" to "RunTimeTicks",
                    "EnableUserData" to "true",
                ),
            ).items.firstOrNull()
        }.getOrNull()

    // --- URL helpers ---------------------------------------------------------

    /** Backdrop image URL for an item, or null if it has none. */
    fun backdropUrl(item: BaseItem, maxWidth: Int = 1920): String? {
        val (id, tag) = when {
            item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first()
            item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() ->
                item.parentBackdropItemId to item.parentBackdropImageTags.first()
            else -> return null
        }
        return imageUrl(id, "Backdrop", tag, maxWidth, directIndex = true)
    }

    /**
     * The item's "Logo" image (the stylised title treatment) from Emby metadata,
     * or null when the item has no logo. Used to show artwork in place of the
     * plain-text title in the screensaver.
     */
    fun logoUrl(item: BaseItem, maxWidth: Int = 800): String? {
        val (id, tag) = when {
            item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo")
            item.parentLogoItemId != null && item.parentLogoImageTag != null ->
                item.parentLogoItemId to item.parentLogoImageTag
            else -> return null
        }
        return imageUrl(id, "Logo", tag, maxWidth)
    }

    /** Primary (poster) image URL, used as a card fallback. */
    fun primaryUrl(item: BaseItem, maxWidth: Int = 500): String? {
        val tag = item.imageTags["Primary"] ?: return null
        return imageUrl(item.id, "Primary", tag, maxWidth)
    }

    /**
     * Poster for an item the app never received as a [BaseItem] — a service alert names
     * its subject by id and tag only, and the TV schedule ids it carries resolve through the
     * gateway's image proxy like any other.
     */
    fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
        if (itemId.isBlank() || tag.isBlank()) return null
        return imageUrl(itemId, "Primary", tag, maxWidth)
    }

    /** Emby stores cast portraits as the person's Primary image. */
    fun personImageUrl(person: com.ponzischeme89.memby.data.model.EmbyPerson, maxWidth: Int = 240): String? =
        posterUrl(person.id, person.primaryImageTag.orEmpty(), maxWidth)

    /**
     * Builds an artwork URL for whichever backend this build uses.
     *
     * Coil fetches these as plain URLs with no interceptor attached, so the credential
     * has to travel in the query string either way: `api_key` for Emby, `t` for the
     * gateway. The gateway form is preferable — that token is revocable and grants
     * nothing but Memby's own API.
     */
    private fun imageUrl(
        itemId: String,
        imageType: String,
        tag: String,
        maxWidth: Int,
        directIndex: Boolean = false,
    ): String? {
        val token = snapshot.token
        ServerConfig.gatewayUrl?.let { gateway ->
            if (token.isNullOrBlank()) return null
            return buildString {
                append(gateway.trimEnd('/'))
                append("/v1/images/").append(itemId).append('/').append(imageType.lowercase())
                append("?maxWidth=").append(maxWidth)
                append("&quality=").append(ARTWORK_QUALITY)
                append("&tag=").append(encode(tag))
                append("&t=").append(encode(token))
            }
        }

        val base = activeServerUrl ?: return null
        return buildString {
            append(base.trimEnd('/'))
            append("/Items/").append(itemId).append("/Images/").append(imageType)
            if (directIndex) append("/0")
            append("?maxWidth=").append(maxWidth)
            append("&quality=").append(ARTWORK_QUALITY)
            append("&tag=").append(encode(tag))
            token?.let { append("&api_key=").append(encode(it)) }
        }
    }

    /**
     * Resolves subtitle URLs the gateway handed back as paths rather than addresses.
     *
     * Almost every subtitle is Emby's and arrives as a complete URL with its own
     * credential on it. The exception is one the gateway fetched itself — from a provider
     * that hands back a file instead of writing it beside the media — which the gateway
     * serves from its own route. It cannot write that as an absolute address because it
     * does not reliably know its externally reachable name; the television does, because
     * it is the thing talking to it. So the server sends a path and this puts the base and
     * the token on it, exactly as [imageUrl] does for artwork, and for the same reason:
     * media3 fetches a sidecar as a plain URL with none of Memby's headers attached.
     *
     * A URL that is already absolute is left alone, so this is safe to run over every
     * playback response rather than only the one that produced a download.
     */
    private fun resolveSubtitleUrls(subtitles: List<PlayableSubtitle>): List<PlayableSubtitle> {
        if (subtitles.none { it.url.startsWith("/") }) return subtitles
        val gateway = ServerConfig.gatewayUrl?.trimEnd('/')
        val token = snapshot.token
        return subtitles.map { subtitle ->
            if (!subtitle.url.startsWith("/")) return@map subtitle
            // Dropping the track is the right failure: a sidecar URL with no credential on
            // it fetches a 401, and media3 reports that as a broken stream rather than as a
            // missing subtitle.
            if (gateway.isNullOrBlank() || token.isNullOrBlank()) {
                return@map subtitle.copy(url = "")
            }
            subtitle.copy(url = gateway + subtitle.url + "?t=" + encode(token))
        }
    }

    fun hasBackdrop(item: BaseItem): Boolean =
        item.backdropImageTags.isNotEmpty() ||
            (item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty())

    private fun buildStreamUrl(itemId: String): String {
        val base = activeServerUrl?.trimEnd('/') ?: error("Not connected")
        val token = snapshot.token.orEmpty()
        val deviceId = snapshot.deviceId.ifEmpty { "memby" }
        return "$base/Videos/$itemId/stream" +
            "?static=true" +
            "&api_key=${encode(token)}" +
            "&DeviceId=${encode(deviceId)}"
    }

    private fun playbackReport(
        session: PlaybackSession,
        positionMs: Long,
        isPaused: Boolean,
        eventName: String?,
    ) =
        PlaybackReport(
            itemId = session.itemId,
            mediaSourceId = session.mediaSourceId,
            playSessionId = session.playSessionId,
            positionTicks = millisecondsToTicks(positionMs),
            isPaused = isPaused,
            playMethod = session.playMethod,
            eventName = eventName,
        )

    private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")

    private companion object {
        /**
         * JPEG quality asked of Emby (or of the gateway's image proxy) for every poster,
         * backdrop and logo.
         *
         * 80 rather than 90: at the distance a television is watched from the two are
         * indistinguishable, and it is roughly a third fewer bytes on every cold image —
         * which is time-to-poster on a launcher that paints fifteen of them at once, and
         * a disk cache that holds correspondingly more.
         *
         * Note this is part of the image URL, and the URL is Coil's cache key, so
         * changing it makes every existing install re-fetch its artwork once.
         */
        const val ARTWORK_QUALITY = 80

        /**
         * Bounds on the three process-lifetime caches that hold whatever the household has
         * browsed. Generous enough that a normal evening never evicts anything, and small
         * enough that a television left on for a month does not end up holding a copy of
         * the catalogue. Ratings has the largest because it is filled by merely putting
         * focus on a card, which is every card on the launcher.
         */
        const val PERSON_CACHE_SIZE = 64
        const val FILMOGRAPHY_CACHE_SIZE = 32
        const val RATINGS_CACHE_SIZE = 512
    }
}

/**
 * A bounded, access-ordered, thread-safe cache.
 *
 * The `LinkedHashMap` eviction pattern the repository already uses for playables, related
 * content and episodes, wrapped so the maps that were plain `ConcurrentHashMap`s can keep
 * their `get`/`put` call sites.
 */
private fun <V> boundedCache(maxEntries: Int): MutableMap<String, V> =
    java.util.Collections.synchronizedMap(
        object : LinkedHashMap<String, V>(maxEntries.coerceAtLeast(1), 0.75f, true) {
            override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, V>?): Boolean =
                size > maxEntries
        },
    )

data class PlaybackSession(
    val itemId: String,
    val mediaSourceId: String,
    val playSessionId: String,
    val playMethod: String = "DirectPlay",
)

private data class PlaybackDiscovery(
    val subtitles: List<PlayableSubtitle> = emptyList(),
    val mediaSourceId: String = "",
    val playSessionId: String = "",
    val playMethod: String = "DirectPlay",
    val url: String? = null,
)

internal data class PlaybackDelivery(
    val url: String?,
    val playMethod: String,
)

/**
 * Honour Emby's negotiated delivery instead of blindly opening the original file.
 * A null URL means the original static stream is genuinely suitable for direct play.
 */
internal fun selectPlaybackDelivery(
    source: MediaSourceInfo,
    forceTranscode: Boolean = false,
): PlaybackDelivery {
    val transcode = source.transcodingUrl?.takeIf(String::isNotBlank)
    val directStream = source.directStreamUrl?.takeIf(String::isNotBlank)
    if (forceTranscode && transcode != null) {
        return PlaybackDelivery(transcode, "Transcode")
    }
    if (source.supportsDirectPlay == true) {
        return PlaybackDelivery(null, "DirectPlay")
    }
    if (source.supportsDirectStream != false && directStream != null) {
        return PlaybackDelivery(directStream, "DirectStream")
    }
    if (source.supportsTranscoding != false && transcode != null) {
        return PlaybackDelivery(transcode, "Transcode")
    }
    if (directStream != null) {
        return PlaybackDelivery(directStream, "DirectStream")
    }
    if (transcode != null) {
        return PlaybackDelivery(transcode, "Transcode")
    }
    return PlaybackDelivery(null, "DirectPlay")
}

private fun PlaybackSession.gatewayReport(
    positionMs: Long,
    isPaused: Boolean,
    eventName: String?,
    durationMs: Long = 0L,
) =
    GatewayPlaybackReport(
        itemId = itemId,
        positionMs = positionMs,
        durationMs = durationMs.coerceAtLeast(0L),
        isPaused = isPaused,
        mediaSourceId = mediaSourceId,
        playSessionId = playSessionId,
        playMethod = playMethod,
        eventName = eventName,
    )

private fun episodeCode(item: BaseItem): String? {
    val season = item.parentIndexNumber ?: return null
    val episode = item.indexNumber ?: return null
    if (season < 0 || episode <= 0) return null
    return "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
}

private data class CachedPlayable(
    val playable: Playable,
    val resolvedAtMs: Long,
    val expiresAtMs: Long,
)

private data class CachedSeriesEpisodes(
    val episodes: List<BaseItem>,
    val expiresAtMs: Long,
)

/**
 * What a detail page can add beyond the item itself.
 *
 * [reasons] is the recommendation engine's explanation of this title *to this viewer* and
 * is empty on the direct path, which has no profile to reason from.
 */
data class RelatedContent(
    val reasons: List<String> = emptyList(),
    val items: List<BaseItem> = emptyList(),
) {
    val isEmpty: Boolean get() = reasons.isEmpty() && items.isEmpty()
}

private data class CachedRelated(
    val content: RelatedContent,
    val expiresAtMs: Long,
)

/**
 * A wrapper rather than the value itself, for the reason [CachedTrickplay] is one: a null
 * [value] is a real answer — this title has no local trailer, which is most of a library —
 * and a map cannot tell that from an absent entry.
 */
private data class CachedTrailer(val value: BaseItem?)

/**
 * A wrapper rather than the value itself, because a null [value] is a real answer — this
 * title has no previews — and a map cannot tell that from an absent entry.
 */
private data class CachedTrickplay(val value: Trickplay?)

/**
 * What one reading of a title's chapter markers came to. Both halves are nullable and both
 * nulls are real answers — most of a library has no intro, and a title can easily have
 * credits and no intro or the other way round.
 */
data class ChapterMarkers(
    val intro: IntroSegment? = null,
    val creditsStartMs: Long? = null,
)

/** An empty reading is a real answer — most titles have no markers — so it is cached too. */
private data class CachedIntro(val value: ChapterMarkers)

private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
// How far back to look for the play that places a Next Up episode in Continue Watching.
// This is a household's recent viewing, not its history: a series nobody has touched in
// this many plays is not competing for the front of the row anyway. Mirrors the gateway's
// continuePlayLookback.
private const val CONTINUE_PLAY_LOOKBACK = 120

/**
 * A viewing session touches a handful of titles — what is playing, and what it advances
 * to. Twelve is generous for that and each entry is a few kilobytes of byte offsets.
 */
private const val TRICKPLAY_CACHE_SIZE = 12

// A season's worth, so working through one show in an evening never asks twice.
private const val INTRO_CACHE_SIZE = 24

/**
 * The thumbnail width to ask Emby for on the direct path. It is not a free parameter:
 * Emby generates previews at the widths its own settings name and answers any other with a
 * well-formed file containing no frames. 320 is what Emby writes by default, and it must
 * match the gateway's own `trickplayWidth` or the two paths would show different previews.
 */
private const val TRICKPLAY_WIDTH = 320

/**
 * How much of the front of a BIF to read while looking for its index. This covers a title
 * of about twenty-two hours at ten seconds a frame, so in practice one request settles it;
 * anything longer costs a second, exact read rather than being refused.
 */
private const val TRICKPLAY_INDEX_WINDOW = 64L * 1024L

/**
 * Raised from six when focus began warming this: a viewer walking a shelf of shows now
 * fills it as they go, and at six the show they finally press could have been evicted by
 * the ones they passed on the way to it — which is the entire case the warm exists for.
 */
private const val SERIES_EPISODE_CACHE_SIZE = 10
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
private const val RELATED_CACHE_SIZE = 12
private const val RELATED_LIMIT = 12

/**
 * Whether a title has a trailer is one boolean and an occasional item, so this can afford
 * to be generous: an evening of browsing touches far more titles than it plays, and the
 * point of the cache is that walking back into a page never asks again.
 */
private const val TRAILER_CACHE_SIZE = 64

// Long enough that walking back and forth between a row and a detail page never re-asks,
// short enough that a newly watched title drops out of "more like this" the same evening.
private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L

internal fun millisecondsToTicks(milliseconds: Long): Long =
    milliseconds.coerceAtLeast(0L) * 10_000L

internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
    resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS

/** A positive position on the pressed card outranks an older prefetched zero. */
internal fun launchResumePositionMs(resolvedPositionMs: Long, requestedPositionMs: Long): Long =
    if (requestedPositionMs > 0L) requestedPositionMs else resolvedPositionMs.coerceAtLeast(0L)

private val BaseItem.resumePositionMs: Long
    get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)

/** Shared across the error-body parsers below; building a Json format per call is costly. */
private val errorBodyJson = Json { ignoreUnknownKeys = true }

/**
 * Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
 * bodies or stack traces (which could contain tokens) to the screen.
 */
fun friendlyEmbyError(t: Throwable): String = when (t) {
    is IOException -> "Can't reach the Emby server. Check your network."
    is HttpException -> when (t.code()) {
        401 -> "Session expired. Open Memby to sign in again."
        403 -> "Access denied by the server."
        404 -> "Not found on the server."
        429 -> retryAfterMessage(t)
        // The gateway answers 503 when an operator has deliberately taken Memby down.
        // Showing their message beats a generic "server problem" the viewer can do
        // nothing about.
        503 -> maintenanceMessage(t) ?: "Memby is unavailable right now. Try again shortly."
        in 500..599 -> "The Emby server had a problem. Try again."
        else -> "Server error (${t.code()})."
    }
    else -> "Something went wrong. Try again."
}

private fun retryAfterMessage(t: HttpException): String {
    val seconds = t.response()?.headers()?.get("Retry-After")?.trim()?.toLongOrNull()
    return if (seconds != null && seconds > 0) {
        "The server is busy. Try again in ${seconds.coerceAtMost(3_600)} seconds."
    } else {
        "The server is busy. Try again shortly."
    }
}

private fun maintenanceMessage(t: HttpException): String? = runCatching {
    parseMaintenanceMessage(t.response()?.errorBody()?.string().orEmpty())
}.getOrNull()

/**
 * Reads the operator's message out of a maintenance response, or null when this 503 is
 * something else. Never surfaces a raw body: only the known `message` field is trusted,
 * and only up to a length that fits on a TV.
 */
internal fun parseMaintenanceMessage(body: String): String? {
    if (body.isBlank()) return null
    return runCatching {
        val parsed = errorBodyJson.decodeFromString<MaintenanceResponse>(body)
        parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160)
    }.getOrNull()
}

/**
 * The server's copy of a viewer's settings, carried in the body of a 409 so adopting it
 * costs no second request. Returns null if the body is not one, in which case the caller
 * lets the original failure stand rather than inventing a document.
 */
private fun parsePreferencesConflict(
    conflict: HttpException,
): com.ponzischeme89.memby.data.model.GatewayPreferences? = runCatching {
    val body = conflict.response()?.errorBody()?.string().orEmpty()
    if (body.isBlank()) return@runCatching null
    errorBodyJson.decodeFromString<com.ponzischeme89.memby.data.model.GatewayPreferences>(body)
}.getOrNull()

/** True when this failure is the gateway reporting a deliberate outage. */
fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503

/** True when the gateway has rejected the persisted session token. */
fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401

/** Only an authoritative authentication rejection permits deleting a saved profile. */
internal fun shouldPreserveSessionAfterValidationFailure(t: Throwable): Boolean =
    !isUnauthorizedError(t)

@Serializable
private data class MaintenanceResponse(
    val maintenance: Boolean = false,
    val message: String? = null,
)

/** Ensures a scheme and strips a trailing slash for consistent base handling. */
fun normalizeServerUrl(raw: String): String {
    var url = raw.trim()
    if (!url.startsWith("http://", true) && !url.startsWith("https://", true)) {
        url = "http://$url"
    }
    return url.trimEnd('/')
}
