package com.ponzischeme89.memby.data

import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.data.playback.SurroundCodec.Companion.asSlugs
import com.ponzischeme89.memby.update.requiredUpdateSatisfied
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.security.MessageDigest
import java.util.UUID

private val settingsJson = Json {
    // A newer build may have written fields this build does not know about (for example
    // after a rollback). Unknown fields must not make every saved profile disappear.
    ignoreUnknownKeys = true
    coerceInputValues = true
}

private const val CURRENT_SETTINGS_SCHEMA = 2
private val settingsSchemaKey = intPreferencesKey("settings_schema_version")
private val migrationProfilesKey = stringPreferencesKey("profiles")
private val migrationProfilesRecoveryKey = stringPreferencesKey("profiles_recovery_v0")

/** The known-bad `ANDROID_ID` a batch of old devices all shipped with. */
private const val SHARED_ANDROID_ID = "9774d56d682e549c"

/**
 * Derives this television's device id from `ANDROID_ID`, hashed so the platform
 * identifier itself is never sent to a server, and prefixed so the value reads as ours in
 * Emby's devices list.
 *
 * Falls back to a random id when the platform has nothing usable — null on a device that
 * has not finished setting up, blank, or the one value a batch of early devices all
 * reported, which would make every one of them the same television. A random id is worse
 * only in that it does not survive a reinstall; sharing one identity between two sets
 * would be wrong rather than merely inconvenient.
 */
internal fun deviceIdFor(androidId: String?): String {
    val trimmed = androidId?.trim().orEmpty()
    if (trimmed.length < 16 || trimmed == SHARED_ANDROID_ID || trimmed.all { it == '0' }) {
        return UUID.randomUUID().toString()
    }
    val digest = MessageDigest.getInstance("SHA-256")
        .digest("memby:$trimmed".toByteArray(Charsets.UTF_8))
    return "memby-" + digest.take(10).joinToString("") { "%02x".format(it) }
}

private data class DecodedProfiles(
    val profiles: List<EmbyProfile>,
    val hadErrors: Boolean,
)

/**
 * Decode profiles independently. One damaged or future-incompatible entry must not hide
 * every other account on the TV, which decoding the complete list in one call would do.
 */
private fun decodeProfilesSafely(value: String?): DecodedProfiles {
    if (value.isNullOrBlank()) return DecodedProfiles(emptyList(), false)
    return runCatching {
        val elements = settingsJson.parseToJsonElement(value) as? JsonArray
            ?: return@runCatching DecodedProfiles(emptyList(), true)
        val decoded = elements.mapNotNull { element ->
            runCatching { settingsJson.decodeFromJsonElement<EmbyProfile>(element) }
                .getOrNull()
                ?.takeIf {
                    it.serverUrl.isNotBlank() && it.token.isNotBlank() &&
                        it.userId.isNotBlank() && it.username.isNotBlank()
                }
        }
        DecodedProfiles(decoded.distinctBy(EmbyProfile::id), decoded.size != elements.size)
    }.getOrElse { DecodedProfiles(emptyList(), true) }
}

/** Versioned and idempotent transformations applied before DataStore emits its first value. */
internal object SettingsMigrationLogic {
    fun migrateToCurrent(source: Preferences): Preferences {
        val migrated = source.toMutablePreferences()
        var version = runCatching { source[settingsSchemaKey] ?: 0 }.getOrDefault(0)
        if (version > CURRENT_SETTINGS_SCHEMA) return source // safe downgrade

        while (version < CURRENT_SETTINGS_SCHEMA) {
            // Each version is a separate step so future changes can be appended without
            // changing an already-shipped transformation.
            when (version) {
                0 -> migrateFromUnversioned(migrated)
                1 -> migrateDeviceTogglesIntoProfiles(migrated)
            }
            version += 1
            migrated[settingsSchemaKey] = version
        }
        return migrated
    }

    private fun migrateFromUnversioned(preferences: MutablePreferences) {
        val rawProfiles = preferences[migrationProfilesKey]
        val decoded = decodeProfilesSafely(rawProfiles)
        if (decoded.hadErrors && !rawProfiles.isNullOrBlank()) {
            // Keep the original payload for a later release to recover more from. The app
            // uses the repaired value below, so this backup can never block startup.
            preferences[migrationProfilesRecoveryKey] = rawProfiles
        }

        val legacy = legacyProfileForMigration(preferences)
        val profiles = decoded.profiles.toMutableList()
        if (legacy != null) {
            val existingIndex = profiles.indexOfFirst {
                it.userId == legacy.userId && it.serverUrl.trimEnd('/') == legacy.serverUrl.trimEnd('/')
            }
            if (existingIndex < 0) {
                profiles += legacy
            } else {
                // Flat keys describe the currently authenticated session and are the best
                // source for credentials; retain the per-profile UI choices from the blob.
                val existing = profiles[existingIndex]
                profiles[existingIndex] = existing.copy(
                    id = legacy.id,
                    serverUrl = legacy.serverUrl,
                    token = legacy.token,
                    userId = legacy.userId,
                    username = legacy.username,
                    serverId = legacy.serverId,
                )
            }
        }
        if (rawProfiles != null || profiles.isNotEmpty()) {
            val stripped = profiles.map { profile ->
                profile.homeCacheJson?.takeIf(String::isNotBlank)?.let { cache ->
                    val cacheKey = stringPreferencesKey("home_cache::${profile.userId}@${profile.serverUrl}")
                    if (preferences[cacheKey] == null) preferences[cacheKey] = cache
                }
                if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
            }
            preferences[migrationProfilesKey] = settingsJson.encodeToString(stripped)
        }
    }

    /**
     * Folds the three toggles that used to be device-wide into every stored profile.
     *
     * `showTitleLogo`, `autoPlayNextEpisode` and `showTenMinuteReminder` became
     * per-profile when settings moved to the server. On an install that predates that,
     * the flat keys hold the viewer's real choices while the saved profiles carry none —
     * so they decode as the class defaults, all three on. Nothing looks wrong at first,
     * because the flat keys are what the app renders from. But [applyProfile] copies a
     * profile's values *into* those flat keys, so the first profile switch after upgrading
     * would silently switch all three back on, and the sync would then push that to the
     * server as a deliberate choice.
     *
     * Copying one device-wide value into every profile is exactly right rather than a
     * compromise: while it was device-wide, that value genuinely was in force for all of
     * them.
     *
     * Overwriting whatever a profile carries is safe because at schema 1 it cannot carry
     * anything — the three fields did not exist on [EmbyProfile] then. It is also the only
     * option available: the encoder omits default values, so a stored profile that chose
     * `true` is byte-identical to one that never chose at all. The schema version is what
     * makes this run exactly once.
     */
    private fun migrateDeviceTogglesIntoProfiles(preferences: MutablePreferences) {
        val profiles = decodeProfilesSafely(preferences[migrationProfilesKey]).profiles
        if (profiles.isEmpty()) return

        val titleLogo = preferences[booleanPreferencesKey("show_title_logo")] ?: true
        val autoPlayNext = preferences[booleanPreferencesKey("auto_play_next_episode")] ?: true
        val tenMinutes = preferences[booleanPreferencesKey("show_ten_minute_reminder")] ?: true
        // All three at their defaults means there is nothing a viewer chose to preserve,
        // and rewriting the blob would be a whole-file fsync for no change.
        if (titleLogo && autoPlayNext && tenMinutes) return

        preferences[migrationProfilesKey] = settingsJson.encodeToString(
            profiles.map {
                it.copy(
                    showTitleLogo = titleLogo,
                    autoPlayNextEpisode = autoPlayNext,
                    showTenMinuteReminder = tenMinutes,
                )
            },
        )
    }

    private fun legacyProfileForMigration(preferences: Preferences): EmbyProfile? {
        val serverUrl = preferences[stringPreferencesKey("server_url")]?.takeIf(String::isNotBlank)
            ?: return null
        val token = preferences[stringPreferencesKey("token")]?.takeIf(String::isNotBlank)
            ?: return null
        val userId = preferences[stringPreferencesKey("user_id")]?.takeIf(String::isNotBlank)
            ?: return null
        val username = preferences[stringPreferencesKey("username")]
            ?.takeIf(String::isNotBlank) ?: "Memby user"
        return EmbyProfile(
            id = "${serverUrl.trimEnd('/')}::$userId",
            serverUrl = serverUrl,
            token = token,
            userId = userId,
            username = username,
            serverId = preferences[stringPreferencesKey("server_id")],
            homeCacheJson = preferences[
                stringPreferencesKey("home_cache::$userId@$serverUrl")
            ] ?: preferences[stringPreferencesKey("home_cache")],
            forYouMinutes = preferences[intPreferencesKey("for_you_minutes")] ?: 0,
            hasOpenedForYou = preferences[booleanPreferencesKey("has_opened_for_you")] ?: false,
            welcomeQuoteStyle = preferences[stringPreferencesKey("welcome_quote_style")]
                ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
            homeSections = preferences[stringPreferencesKey("home_sections")]
                ?: Settings.DEFAULT_HOME_SECTIONS,
            homeCardDensity = preferences[stringPreferencesKey("home_card_density")]
                ?: Settings.DEFAULT_HOME_CARD_DENSITY,
            homeArtworkStyle = preferences[stringPreferencesKey("home_artwork_style")]
                ?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
            showHomeCardMetadata = preferences[
                booleanPreferencesKey("show_home_card_metadata")
            ] ?: true,
            showRatingsStrip = preferences[booleanPreferencesKey("show_ratings_strip")] ?: true,
            hideWatchedMovies = preferences[booleanPreferencesKey("hide_watched_movies")] ?: false,
            homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(),
            homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(),
            homeHiddenRows = preferences[stringPreferencesKey("home_hidden_rows")].orEmpty(),
            preferencesRevision = preferences[longPreferencesKey("preferences_revision")] ?: 0,
            // These were device-wide before settings moved to the server, so the values
            // this install already had become the first profile's. Defaulting them here
            // instead would silently turn three settings back on for everyone upgrading.
            showTitleLogo = preferences[booleanPreferencesKey("show_title_logo")] ?: true,
            autoPlayNextEpisode = preferences[
                booleanPreferencesKey("auto_play_next_episode")
            ] ?: true,
            showTenMinuteReminder = preferences[
                booleanPreferencesKey("show_ten_minute_reminder")
            ] ?: true,
        )
    }
}

/** The built-in launcher rows a viewer may choose between. */
internal val VALID_HOME_SECTIONS = setOf("continue", "favorites", "latest")

private class SettingsPreferencesMigration : DataMigration<Preferences> {
    override suspend fun shouldMigrate(currentData: Preferences): Boolean =
        runCatching { (currentData[settingsSchemaKey] ?: 0) < CURRENT_SETTINGS_SCHEMA }
            .getOrDefault(true)

    override suspend fun migrate(currentData: Preferences): Preferences =
        // Migration code must never turn into a permanent startup/edit failure. All known
        // data remains readable on the current code path, and leaving the version alone
        // lets the next process start retry a transiently failed migration.
        runCatching { SettingsMigrationLogic.migrateToCurrent(currentData) }
            .getOrElse { currentData }

    override suspend fun cleanUp() = Unit
}

/**
 * The corruption handler is not optional here. Without one, a Preferences file that fails
 * to parse makes [DataStore.data] throw on every read for the life of the install — and
 * because [SettingsStore.settingsFlow] is the gate the launcher waits behind, that shows
 * up as "Opening Memby…" forever, surviving every relaunch. Preferences DataStore rewrites
 * and fsyncs the whole file on each edit, and this app edits it on every home refresh, so
 * a process killed mid-write (backgrounding a TV app is exactly when that happens) is a
 * real possibility rather than a theoretical one. Starting again from empty preferences
 * costs a sign-in; throwing costs the app.
 */
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = "emby_settings",
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
    produceMigrations = { listOf(SettingsPreferencesMigration()) },
)

/** Persisted connection state. */
data class Settings(
    val serverUrl: String? = null,
    val token: String? = null,
    val userId: String? = null,
    val serverId: String? = null,
    val username: String? = null,
    val deviceId: String = "",
    val deviceName: String = "",
    val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
    // Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access
    // token for the (private) repo's release API and asset downloads.
    val updateBaseUrl: String? = null,
    val updateRepo: String? = null,
    val updateToken: String? = null,
    // Device-level: Back-button behaviour belongs to the television and its remote, not
    // to the signed-in viewer, so it deliberately does not travel through UserPreferences.
    val confirmExitMemby: Boolean = false,
    // Show each item's Emby "Logo" image in place of the plain-text title.
    val showTitleLogo: Boolean = true,
    // Slide up a "next up" banner near the end of an episode and roll into the next one.
    val autoPlayNextEpisode: Boolean = true,
    // Show the compact lower-third when playback crosses ten minutes remaining.
    val showTenMinuteReminder: Boolean = true,
    // Turn a subtitle track on automatically, and which language wins when one is chosen.
    // Per-profile and synced: subtitles are a personal choice, not a property of the room.
    val subtitlesEnabled: Boolean = true,
    val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
    // How far one press of Left or Right moves the film. Per-profile and synced for the
    // same reason subtitles are: it is a habit, not a property of the room.
    val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
    // What to do when an episode reaches its opening titles: offer a button, skip without
    // asking, or nothing. Per-profile and synced for the same reason the two above are.
    val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
    // Shrink the closing credits to one side at double speed with what is on next beside
    // them. Per-profile and synced for the same reason the three above are.
    val speedUpCredits: Boolean = true,
    /**
     * Whether surround formats are bitstreamed to the receiver by what the hardware probe
     * reported, or by the viewer's own per-codec switches.
     *
     * Device-level, and deliberately not synced. What a soundbar accepts is a property of
     * the room, not of the person sitting in it — pushing one lounge's DTS override onto
     * every television in the house is exactly the failure the manual mode exists to fix.
     * [audioPassthroughCodecs] is a comma-separated list of [SurroundCodec] slugs and is
     * read only in manual mode.
     */
    val audioPassthroughMode: String = DEFAULT_AUDIO_PASSTHROUGH_MODE,
    val audioPassthroughCodecs: String = "",
    // Foreground colour of the slide-progress ring, as an RRGGBB hex string.
    val ringColorHex: String = DEFAULT_RING_COLOR,
    val lastBackdropUrl: String? = null,
    /** Comma-separated, user-controlled order of rows shown on the client home. */
    val homeSections: String = DEFAULT_HOME_SECTIONS,
    val homeCacheJson: String? = null,
    /** Last duration chosen in For You, scoped to the active Emby profile on this TV. */
    val forYouMinutes: Int = 0,
    /** Whether this profile has discovered the dedicated For You destination. */
    val hasOpenedForYou: Boolean = false,
    val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
    /** Card image selection on browse rows: automatic, poster, or backdrop. */
    val homeArtworkStyle: String = DEFAULT_HOME_ARTWORK_STYLE,
    val showHomeCardMetadata: Boolean = true,
    /** Show third-party ratings on browse and detail surfaces. Profile-specific. */
    val showRatingsStrip: Boolean = true,
    /** Hide fully watched movies from browse rows and hero selections. */
    val hideWatchedMovies: Boolean = false,
    /** Newline-separated server row ids. These are profile-specific home customisations. */
    val homeRowOrder: String = "",
    val homePinnedRows: String = "",
    val homeHiddenRows: String = "",
    /** Tone used for the short welcome line after sign-in and during startup. */
    val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE,
    /**
     * The colour scheme this viewer chose, as a server theme id. Profile-specific and
     * synced like the rest, so it follows the person to every set they sign into.
     *
     * It is only ever the *choice*. What is actually on screen is resolved by the gateway
     * on top of it — a season outranks it, and an operator's allowlist can withdraw it —
     * and that answer arrives as [themePaletteJson] below rather than as a change here.
     * Keeping them apart is what lets a viewer's own scheme survive underneath Christmas
     * and come back on the 27th without anybody re-choosing it.
     */
    val themeId: String = DEFAULT_THEME_ID,
    /**
     * The palette the gateway last handed this television, verbatim, and the revision it
     * came at. Device state rather than a synced preference: it is a *cache* of a server
     * answer, not a decision, and its whole job is to be on disk before the first request
     * of a cold start returns so the launcher does not paint itself in the default colours
     * and then flick into the viewer's.
     */
    val themePaletteJson: String? = null,
    val themeRevision: String = "",
    /**
     * User ids known to have finished recommendation onboarding on this TV. Cold start
     * consults this instead of waiting on the gateway: onboarding is a one-time,
     * monotonic fact, and blocking the launcher on a round-trip to re-learn it defeats
     * the whole point of [homeCacheJson]. The gateway is still asked in the background
     * and remains authoritative for anyone not listed here.
     */
    val onboardedUserIds: Set<String> = emptySet(),
    /**
     * The app version this television has already announced. Deliberately device state
     * rather than a synced preference: what is new is a property of the APK sitting on
     * this set, and a viewer who signs into a second TV that is still a version behind has
     * not seen that build's update notice.
     */
    val whatsNewSeenVersion: String? = null,
    /**
     * The version the gateway last refused this build over, or null while it has said
     * nothing. Device state, and deliberately outlives both the session and the process:
     * the refusal is announced exactly once — the 401 that deletes the session carries the
     * header, every 401 after it is an ordinary missing session — so a television that was
     * told and then restarted has nothing left to learn it from but this. It is what stops
     * the launcher drawing a sign-in form the gateway is going to refuse.
     */
    val requiredUpdateVersion: String? = null,
    /**
     * Revision of the active profile's settings as last agreed with the gateway. Zero
     * means this TV has never synced them, which is what tells [PreferencesSync] to push
     * what is here rather than wait to be told.
     */
    val preferencesRevision: Long = 0,
    val profiles: List<EmbyProfile> = emptyList(),
) {
    val isSignedIn: Boolean
        get() = !serverUrl.isNullOrBlank() && !token.isNullOrBlank() && !userId.isNullOrBlank()

    val activeProfileId: String?
        get() = profiles.firstOrNull {
            it.userId == userId && it.serverUrl == serverUrl
        }?.id

    /** True once this TV has seen the active profile finish onboarding. */
    val hasCompletedOnboarding: Boolean
        get() = userId?.let { it in onboardedUserIds } == true

    companion object {
        const val DEFAULT_ROTATION_SECONDS = 15
        const val DEFAULT_RING_COLOR = "FFFFFF"
        val DEFAULT_AUDIO_PASSTHROUGH_MODE = AudioPassthroughMode.DEFAULT.value
        const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest"
        const val DEFAULT_HOME_CARD_DENSITY = "standard"
        const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
        const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
        /** Matches `defaultThemeID` in the gateway's theme catalogue. */
        const val DEFAULT_THEME_ID = "midnight"
        val EMPTY = Settings()
    }
}

/**
 * The two stored keys read back as the one decision the player and the device profile ask
 * for. Defined on a nullable receiver because most callers hold [SettingsStore.current],
 * which is null until DataStore has emitted — and "not read yet" must mean automatic
 * rather than manual with nothing chosen, which is manual with everything switched off.
 */
val Settings?.audioPassthroughPreference: AudioPassthroughPreference
    get() = this?.let {
        AudioPassthroughPreference(
            mode = AudioPassthroughMode.from(it.audioPassthroughMode),
            codecs = SurroundCodec.setFrom(it.audioPassthroughCodecs),
        )
    } ?: AudioPassthroughPreference.AUTOMATIC

@Serializable
data class EmbyProfile(
    val id: String,
    val serverUrl: String,
    val token: String,
    val userId: String,
    val username: String,
    val serverId: String? = null,
    val homeCacheJson: String? = null,
    val forYouMinutes: Int = 0,
    val hasOpenedForYou: Boolean = false,
    val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
    val homeSections: String = Settings.DEFAULT_HOME_SECTIONS,
    val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
    val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
    val showHomeCardMetadata: Boolean = true,
    val showRatingsStrip: Boolean = true,
    val hideWatchedMovies: Boolean = false,
    val homeRowOrder: String = "",
    val homePinnedRows: String = "",
    val homeHiddenRows: String = "",
    /** Per profile, because two people on one TV have separate documents on the server. */
    val preferencesRevision: Long = 0,
    /** Playback and presentation choices, which sync alongside the home ones. */
    val showTitleLogo: Boolean = true,
    val autoPlayNextEpisode: Boolean = true,
    val showTenMinuteReminder: Boolean = true,
    val subtitlesEnabled: Boolean = true,
    val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
    val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
    val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
    val speedUpCredits: Boolean = true,
    /**
     * The colour scheme this person chose. Per profile like the rest — two people sharing a
     * television have separate documents on the server and separate schemes on it.
     *
     * The resolved *palette* is deliberately not here: it is device state, because it is a
     * cache of a server answer rather than a choice, and stuffing eight colours into the
     * profiles blob would put them into the file that is rewritten on every settings edit.
     */
    val themeId: String = Settings.DEFAULT_THEME_ID,
)

class SettingsStore(private val context: Context) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    @Volatile
    private var latestSettings: Settings? = null

    /** Last home cache actually written, so an unchanged refresh skips the disk entirely. */
    @Volatile
    private var lastPersistedHomeCache: String? = null

    /** The decoded home cache, paired with the JSON it came from. See [primeHomeCache]. */
    @Volatile
    private var decodedHomeCache: Pair<String, HomeCache?>? = null

    val current: Settings?
        get() = latestSettings

    private object Keys {
        const val MAX_SEEN_ALERTS = 40
        val SERVER_URL = stringPreferencesKey("server_url")
        val TOKEN = stringPreferencesKey("token")
        val USER_ID = stringPreferencesKey("user_id")
        val SERVER_ID = stringPreferencesKey("server_id")
        val USERNAME = stringPreferencesKey("username")
        val DEVICE_ID = stringPreferencesKey("device_id")
        val DEVICE_NAME = stringPreferencesKey("device_name")
        val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds")
        val UPDATE_BASE_URL = stringPreferencesKey("update_base_url")
        val UPDATE_REPO = stringPreferencesKey("update_repo")
        val UPDATE_TOKEN = stringPreferencesKey("update_token")
        val CONFIRM_EXIT_MEMBY = booleanPreferencesKey("confirm_exit_memby")
        val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
        val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
        val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
        val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
        val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
        val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
        val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
        val SPEED_UP_CREDITS = booleanPreferencesKey("speed_up_credits")
        val AUDIO_PASSTHROUGH_MODE = stringPreferencesKey("audio_passthrough_mode")
        val AUDIO_PASSTHROUGH_CODECS = stringPreferencesKey("audio_passthrough_codecs")
        val RING_COLOR = stringPreferencesKey("ring_color")
        val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
        val HOME_SECTIONS = stringPreferencesKey("home_sections")
        val HOME_CACHE = stringPreferencesKey("home_cache")
        val FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes")
        val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
        val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
        val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
        val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
        val SHOW_RATINGS_STRIP = booleanPreferencesKey("show_ratings_strip")
        val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
        val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
        val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
        val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
        val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
        val THEME_ID = stringPreferencesKey("theme_id")
        val THEME_PALETTE = stringPreferencesKey("theme_palette")
        val THEME_REVISION = stringPreferencesKey("theme_revision")
        val PROFILES = stringPreferencesKey("profiles")
        val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
        val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
        val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
        val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
        val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
    }

    /**
     * Start the single disk read as soon as the Application creates this store, then
     * replay the decoded value to activities, repositories and services. Previously
     * every collector repeated the cold mapping work and the home screen could be
     * created before the repository had seen the saved cache.
     */
    val settingsFlow: Flow<Settings> = context.dataStore.data
        .map(::settingsFrom)
        .distinctUntilChanged()
        .onEach {
            latestSettings = it
            // Decode the launcher's cached rows here, on this store's IO scope, rather
            // than leaving it for HomeViewModel's constructor — which Compose runs on the
            // main thread during the first composition, parsing a blob that can hold
            // every row and every item in it. AppRoot does not compose HomeScreen until
            // this flow has emitted, so by the time the view model asks, the answer is
            // already in memory.
            primeHomeCache(it)
        }
        // A throw here would complete the sharing coroutine, and a SharedFlow that has
        // completed never emits again — every collector for the rest of the process waits
        // forever, which the launcher renders as its loading screen. Nothing this pipeline
        // does is worth that, so a failed read degrades to defaults and the app carries on
        // to sign-in instead of hanging. The corruption handler on [dataStore] covers the
        // parse failure; this covers everything else, including an I/O error on a TV whose
        // storage is briefly unavailable.
        .catch { emit(Settings.EMPTY.also { latestSettings = it }) }
        .shareIn(scope, started = SharingStarted.Eagerly, replay = 1)

    suspend fun setRotationIntervalSeconds(seconds: Int) {
        context.dataStore.edit { it[Keys.ROTATION_SECONDS] = seconds }
    }

    suspend fun setDeviceName(name: String) {
        context.dataStore.edit { it[Keys.DEVICE_NAME] = name.trim().take(80) }
    }

    /** Persists the Gitea update source. Blank values are cleared. */
    suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) {
        context.dataStore.edit {
            fun put(key: Preferences.Key<String>, value: String) {
                val v = value.trim()
                if (v.isEmpty()) it.remove(key) else it[key] = v
            }
            put(Keys.UPDATE_BASE_URL, baseUrl)
            put(Keys.UPDATE_REPO, repo)
            put(Keys.UPDATE_TOKEN, token)
        }
    }

    suspend fun setConfirmExitMemby(enabled: Boolean) {
        context.dataStore.edit { it[Keys.CONFIRM_EXIT_MEMBY] = enabled }
    }

    /**
     * Ids of alerts already shown on this TV. The gateway keeps offering an alert for as
     * long as it is current, so without this an "aired" banner would return every poll —
     * and again after every relaunch. Only the most recent ids are kept; older ones have
     * long since fallen out of the server's window.
     */
    suspend fun seenAlertIds(): Set<String> =
        decodeAlertIds(context.dataStore.data.first()[Keys.SEEN_ALERTS]).toSet()

    suspend fun markAlertSeen(id: String) {
        val trimmed = id.trim()
        if (trimmed.isEmpty()) return
        context.dataStore.edit { preferences ->
            val ids = decodeAlertIds(preferences[Keys.SEEN_ALERTS]).filterNot { it == trimmed }
            preferences[Keys.SEEN_ALERTS] = (ids + trimmed)
                .takeLast(Keys.MAX_SEEN_ALERTS)
                .joinToString("\n")
        }
    }

    private fun decodeAlertIds(raw: String?): List<String> =
        raw?.split('\n')?.filter { it.isNotBlank() }.orEmpty()

    // These three used to be device-wide. They are per-profile now for the same reason
    // they sync: they describe how a *person* wants Memby to behave, so two viewers
    // sharing a television must be able to disagree about them — and a device-wide value
    // would mean whoever signed in last pushed their choice into everyone's account.
    suspend fun setShowTitleLogo(enabled: Boolean) {
        context.dataStore.edit { preferences ->
            preferences[Keys.SHOW_TITLE_LOGO] = enabled
            updateActiveProfile(preferences) { it.copy(showTitleLogo = enabled) }
        }
    }

    suspend fun setAutoPlayNextEpisode(enabled: Boolean) {
        context.dataStore.edit { preferences ->
            preferences[Keys.AUTO_PLAY_NEXT] = enabled
            updateActiveProfile(preferences) { it.copy(autoPlayNextEpisode = enabled) }
        }
    }

    suspend fun setShowTenMinuteReminder(enabled: Boolean) {
        context.dataStore.edit { preferences ->
            preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled
            updateActiveProfile(preferences) { it.copy(showTenMinuteReminder = enabled) }
        }
    }

    /**
     * Records what the viewer just did in the player's subtitle menu.
     *
     * Both halves move together because the menu only ever produces one of two answers:
     * "off", or a specific track — and a track that has no language of its own must not
     * quietly overwrite the language somebody chose earlier, which is why a blank language
     * leaves that half alone.
     */
    suspend fun setSubtitlePreference(enabled: Boolean, language: String?) {
        val normalized = normalizeSubtitleLanguage(language)
        context.dataStore.edit { preferences ->
            preferences[Keys.SUBTITLES_ENABLED] = enabled
            if (normalized.isNotEmpty()) preferences[Keys.SUBTITLE_LANGUAGE] = normalized
            updateActiveProfile(preferences) {
                it.copy(
                    subtitlesEnabled = enabled,
                    subtitleLanguage = if (normalized.isEmpty()) it.subtitleLanguage else normalized,
                )
            }
        }
    }

    /** Normalised on the way in, so a bad value can never reach the player as a step size. */
    suspend fun setSeekIntervalSeconds(seconds: Int) {
        val interval = normalizeSeekIntervalSeconds(seconds)
        context.dataStore.edit { preferences ->
            preferences[Keys.SEEK_INTERVAL_SECONDS] = interval
            updateActiveProfile(preferences) { it.copy(seekIntervalSeconds = interval) }
        }
    }

    /** Normalised on the way in, so a mode this build has no vocabulary for never reaches
     * the player as an instruction. */
    suspend fun setSkipIntroMode(mode: String) {
        val normalized = normalizeSkipIntroMode(mode)
        context.dataStore.edit { preferences ->
            preferences[Keys.SKIP_INTRO_MODE] = normalized
            updateActiveProfile(preferences) { it.copy(skipIntroMode = normalized) }
        }
    }

    suspend fun setSpeedUpCredits(enabled: Boolean) {
        context.dataStore.edit { preferences ->
            preferences[Keys.SPEED_UP_CREDITS] = enabled
            updateActiveProfile(preferences) { it.copy(speedUpCredits = enabled) }
        }
    }

    /**
     * The viewer picking a colour scheme. Not validated here on purpose: the legal ids are
     * the gateway's catalogue and this build has no copy of it, so the only honest check is
     * the one the server makes when the choice is pushed. An id it rejects comes back as the
     * default on the next pull, which is the same correction every other setting gets.
     *
     * Nothing repaints from this write. What is on screen is the *resolved* palette, and
     * that arrives from `ThemeSync` a moment later — which is also what makes a choice the
     * operator has withdrawn, or one covered by a season, visibly not take effect rather
     * than take effect and then be undone.
     */
    suspend fun setThemeId(themeId: String) {
        val trimmed = themeId.trim().ifEmpty { return }
        context.dataStore.edit { preferences ->
            preferences[Keys.THEME_ID] = trimmed
            updateActiveProfile(preferences) { it.copy(themeId = trimmed) }
        }
    }

    /**
     * Caches the palette the gateway resolved, so the next cold start paints in the right
     * colours before any request returns.
     *
     * Written in one edit with the revision it came at, the same invariant
     * [applyRemotePreferences] keeps and for the same reason: the revision is the only thing
     * that decides whether to fetch, and a set holding a revision without the palette it
     * describes would never ask again.
     *
     * The unchanged case is skipped rather than written, because this runs off a poll: a
     * store rewritten every ten seconds to say nothing new is exactly the cost the revision
     * exists to avoid.
     */
    suspend fun setThemePalette(paletteJson: String, revision: String) {
        if (latestSettings?.themePaletteJson == paletteJson &&
            latestSettings?.themeRevision == revision
        ) {
            return
        }
        context.dataStore.edit { preferences ->
            preferences[Keys.THEME_PALETTE] = paletteJson
            preferences[Keys.THEME_REVISION] = revision
        }
    }

    /**
     * Adopts the server's document for the signed-in viewer, in one write.
     *
     * One write is the point. Preferences DataStore rewrites and fsyncs the whole file per
     * edit, and this touches nineteen keys plus the profiles blob — doing it through the
     * individual setters would be eighteen rewrites for one sync, on a TV that has just
     * started up.
     *
     * The revision is stored in the same edit as the values it describes. If they could
     * come apart, a TV could believe it was up to date while holding something else, and
     * nothing would ever correct it: the status poll only compares numbers.
     */
    suspend fun applyRemotePreferences(preferences: UserPreferences, revision: Long) {
        val sections = preferences.homeSections
            .filter { it in VALID_HOME_SECTIONS }
            .distinct()
            .ifEmpty { listOf("favorites") }
            .joinToString(",")
        context.dataStore.edit { store ->
            store[Keys.HOME_SECTIONS] = sections
            store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity
            store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
            store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
            store[Keys.SHOW_RATINGS_STRIP] = preferences.showRatingsStrip
            store[Keys.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
            store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
            store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
            store[Keys.THEME_ID] = preferences.themeId
            store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
            store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
            store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
            store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
            store[Keys.SEEK_INTERVAL_SECONDS] =
                normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
            store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
            store[Keys.SPEED_UP_CREDITS] = preferences.speedUpCredits
            store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
            store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
            store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
            store[Keys.HOME_HIDDEN_ROWS] = preferences.homeHiddenRows.joinToString("\n")
            store[Keys.PREFERENCES_REVISION] = revision
            updateActiveProfile(store) {
                it.copy(
                    homeSections = sections,
                    homeCardDensity = preferences.homeCardDensity,
                    homeArtworkStyle = preferences.homeArtworkStyle,
                    showHomeCardMetadata = preferences.showHomeCardMetadata,
                    showRatingsStrip = preferences.showRatingsStrip,
                    hideWatchedMovies = preferences.hideWatchedMovies,
                    showTitleLogo = preferences.showTitleLogo,
                    welcomeQuoteStyle = preferences.welcomeQuoteStyle,
                    themeId = preferences.themeId,
                    autoPlayNextEpisode = preferences.autoPlayNextEpisode,
                    showTenMinuteReminder = preferences.showTenMinuteReminder,
                    subtitlesEnabled = preferences.subtitlesEnabled,
                    subtitleLanguage = preferences.subtitleLanguage,
                    seekIntervalSeconds =
                        normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
                    skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
                    speedUpCredits = preferences.speedUpCredits,
                    forYouMinutes = preferences.forYouMinutes,
                    homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
                    homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
                    homeHiddenRows = preferences.homeHiddenRows.joinToString("\n"),
                    preferencesRevision = revision,
                )
            }
        }
    }

    /**
     * Records how surround audio should reach the receiver on *this* television.
     *
     * Both halves are written in one edit. They describe one decision, and DataStore
     * rewrites the whole file per edit — a mode landing apart from the switches it selects
     * would leave a set momentarily in manual mode with nothing chosen, which is silence
     * on every surround track it opens in that window.
     */
    suspend fun setAudioPassthrough(
        mode: AudioPassthroughMode,
        codecs: Set<SurroundCodec>,
    ) {
        context.dataStore.edit { preferences ->
            preferences[Keys.AUDIO_PASSTHROUGH_MODE] = mode.value
            preferences[Keys.AUDIO_PASSTHROUGH_CODECS] = codecs.asSlugs()
        }
    }

    /**
     * Records the revision a successful push was stored under, without touching values.
     * Separate from [applyRemotePreferences] because after a push this TV already holds
     * what the server does — rewriting the values would be a redundant file rewrite and,
     * worse, could clobber an edit made in the moment the request was in flight.
     */
    suspend fun setPreferencesRevision(revision: Long) {
        context.dataStore.edit { preferences ->
            preferences[Keys.PREFERENCES_REVISION] = revision
            updateActiveProfile(preferences) { it.copy(preferencesRevision = revision) }
        }
    }

    suspend fun setRingColor(hex: String) {
        context.dataStore.edit { it[Keys.RING_COLOR] = hex }
    }

    suspend fun setLastBackdropUrl(url: String) {
        context.dataStore.edit { it[Keys.LAST_BACKDROP_URL] = url }
    }

    suspend fun setHomeSections(sections: List<String>) {
        val valid = sections.filter { it in VALID_HOME_SECTIONS }.distinct()
        val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",")
        context.dataStore.edit { preferences ->
            preferences[Keys.HOME_SECTIONS] = selected
            updateActiveProfile(preferences) { it.copy(homeSections = selected) }
        }
    }

    suspend fun setHomeCardDensity(density: String) {
        val selected = density.takeIf { it in setOf("compact", "standard", "large") }
            ?: Settings.DEFAULT_HOME_CARD_DENSITY
        context.dataStore.edit { preferences ->
            preferences[Keys.HOME_CARD_DENSITY] = selected
            updateActiveProfile(preferences) { it.copy(homeCardDensity = selected) }
        }
    }

    suspend fun setHomeArtworkStyle(style: String) {
        val selected = style.takeIf { it in setOf("automatic", "poster", "backdrop") }
            ?: Settings.DEFAULT_HOME_ARTWORK_STYLE
        context.dataStore.edit { preferences ->
            preferences[Keys.HOME_ARTWORK_STYLE] = selected
            updateActiveProfile(preferences) { it.copy(homeArtworkStyle = selected) }
        }
    }

    suspend fun setShowHomeCardMetadata(show: Boolean) {
        context.dataStore.edit { preferences ->
            preferences[Keys.SHOW_HOME_CARD_METADATA] = show
            updateActiveProfile(preferences) { it.copy(showHomeCardMetadata = show) }
        }
    }

    suspend fun setShowRatingsStrip(show: Boolean) {
        context.dataStore.edit { preferences ->
            preferences[Keys.SHOW_RATINGS_STRIP] = show
            updateActiveProfile(preferences) { it.copy(showRatingsStrip = show) }
        }
    }

    suspend fun setHideWatchedMovies(hide: Boolean) {
        context.dataStore.edit { preferences ->
            preferences[Keys.HIDE_WATCHED_MOVIES] = hide
            updateActiveProfile(preferences) { it.copy(hideWatchedMovies = hide) }
        }
    }

    suspend fun setHomeRowPreferences(
        order: List<String>,
        pinned: Set<String>,
        hidden: Set<String>,
    ) {
        fun encode(values: Iterable<String>): String = values
            .map(String::trim)
            .filter { it.isNotEmpty() && '\n' !in it }
            .distinct()
            .joinToString("\n")
        val encodedOrder = encode(order)
        val encodedPinned = encode(pinned)
        val encodedHidden = encode(hidden - pinned)
        context.dataStore.edit { preferences ->
            preferences[Keys.HOME_ROW_ORDER] = encodedOrder
            preferences[Keys.HOME_PINNED_ROWS] = encodedPinned
            preferences[Keys.HOME_HIDDEN_ROWS] = encodedHidden
            updateActiveProfile(preferences) {
                it.copy(
                    homeRowOrder = encodedOrder,
                    homePinnedRows = encodedPinned,
                    homeHiddenRows = encodedHidden,
                )
            }
        }
    }

    suspend fun setWelcomeQuoteStyle(style: String) {
        val selected = style.takeIf { it in setOf("neutral", "positive", "homicidal") }
            ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE
        context.dataStore.edit { preferences ->
            preferences[Keys.WELCOME_QUOTE_STYLE] = selected
            updateActiveProfile(preferences) { it.copy(welcomeQuoteStyle = selected) }
        }
    }

    /**
     * Persists the launcher's rows so the next cold start can draw before the network
     * answers.
     *
     * This used to encode the cache, then re-encode the entire profiles blob with a copy
     * of that same JSON embedded in the matching profile — and Preferences DataStore
     * rewrites and fsyncs the whole file on every edit, so each call wrote the cache
     * twice over, plus a copy for every other profile that was already carrying one. It
     * runs on every home refresh and again on every playback stop, which is exactly when
     * the viewer is watching the launcher redraw.
     *
     * Now each profile's cache lives under its own key ([profileHomeCacheKey]) and the
     * profiles blob carries none, so a refresh writes one value and the blob stays small.
     */
    suspend fun setHomeCache(cache: HomeCache) {
        val encodedCache = settingsJson.encodeToString(cache)
        // Most refreshes find nothing new — a scheduled poll, or a playback stop on a row
        // that did not move. Skipping the write means skipping the whole file rewrite.
        if (encodedCache == lastPersistedHomeCache) return
        context.dataStore.edit { preferences ->
            val profileKey = profileHomeCacheKey(
                userId = preferences[Keys.USER_ID],
                serverUrl = preferences[Keys.SERVER_URL],
            )
            if (profileKey == null) {
                // No active profile to key against; the flat slot is all there is.
                preferences[Keys.HOME_CACHE] = encodedCache
            } else {
                // Exactly one copy. Writing the flat key as well put the largest value
                // this store holds into the file twice, and DataStore rewrites and fsyncs
                // the whole file on every edit — so each home refresh was paying double
                // for a value nothing read twice. [activeHomeCache] reads this key first.
                preferences[profileKey] = encodedCache
                preferences.remove(Keys.HOME_CACHE)
            }
        }
        lastPersistedHomeCache = encodedCache
    }

    /**
     * Where a given profile's home cache is stored. Null when there is no active profile
     * to key it against, in which case only the flat [Keys.HOME_CACHE] is written.
     */
    private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key<String>? {
        if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null
        return stringPreferencesKey("home_cache::$userId@$serverUrl")
    }

    /**
     * The active profile's cached rows. The per-profile key is authoritative; the flat
     * [Keys.HOME_CACHE] slot survives only for installs written before the split and for
     * the case where there is no profile to key against.
     */
    private fun activeHomeCache(preferences: Preferences): String? =
        profileHomeCacheKey(
            userId = preferences[Keys.USER_ID],
            serverUrl = preferences[Keys.SERVER_URL],
        )?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE]

    suspend fun setForYouMinutes(minutes: Int) {
        val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
        context.dataStore.edit { preferences ->
            preferences[Keys.FOR_YOU_MINUTES] = selected
            updateActiveProfile(preferences) { it.copy(forYouMinutes = selected) }
        }
    }

    /**
     * Records that a profile has finished recommendation onboarding, so the next cold
     * start can go straight to the launcher instead of waiting on the gateway to say so.
     * Additive only — nothing here ever un-onboards a user, because the failure mode of a
     * wrongly-cleared flag (a returning viewer shown the rating screen again) is worse
     * than the failure mode of a stale one (a request the gateway answers anyway).
     */
    suspend fun markOnboardingCompleted(userId: String) {
        val trimmed = userId.trim()
        if (trimmed.isEmpty()) return
        context.dataStore.edit { preferences ->
            preferences[Keys.ONBOARDED_USERS] = preferences[Keys.ONBOARDED_USERS].orEmpty() + trimmed
        }
    }

    /**
     * Records the version announced on this television, so the update toast appears exactly
     * once. Also written silently on a fresh install, which has not updated from an earlier
     * build and therefore has nothing to announce.
     */
    suspend fun markWhatsNewSeen(version: String) {
        val trimmed = version.trim()
        if (trimmed.isEmpty()) return
        context.dataStore.edit { preferences ->
            preferences[Keys.WHATS_NEW_VERSION] = trimmed
        }
    }

    /**
     * Records that the gateway has refused this build, so the refusal survives the process
     * it arrived in. Ignored once this television is already running the version being
     * demanded: the flag is about the APK, and one that has since been replaced is not the
     * one that was retired.
     */
    suspend fun markRequiredUpdate(
        version: String,
        installedVersion: String = BuildConfig.VERSION_NAME,
    ) {
        val trimmed = version.trim()
        if (trimmed.isEmpty() || requiredUpdateSatisfied(installedVersion, trimmed)) return
        if (current?.requiredUpdateVersion == trimmed) return
        context.dataStore.edit { preferences ->
            preferences[Keys.REQUIRED_UPDATE_VERSION] = trimmed
        }
    }

    /**
     * Forgets the refusal, once the gateway has answered with something that is not a
     * required update. Only the gateway's own verdict may do this — a failed check is the
     * server being unreachable, which is no evidence at all about which builds it accepts.
     */
    suspend fun clearRequiredUpdate() {
        if (current?.requiredUpdateVersion == null) return
        context.dataStore.edit { preferences ->
            preferences.remove(Keys.REQUIRED_UPDATE_VERSION)
        }
    }

    suspend fun markForYouOpened() {
        context.dataStore.edit { preferences ->
            preferences[Keys.HAS_OPENED_FOR_YOU] = true
            updateActiveProfile(preferences) { it.copy(hasOpenedForYou = true) }
        }
    }

    private fun updateActiveProfile(
        preferences: MutablePreferences,
        transform: (EmbyProfile) -> EmbyProfile,
    ) {
        val activeUserId = preferences[Keys.USER_ID]
        val activeServer = preferences[Keys.SERVER_URL]
        val profiles = profilesFrom(preferences).map { profile ->
            if (profile.userId == activeUserId && profile.serverUrl == activeServer) {
                transform(profile)
            } else {
                profile
            }
        }
        if (profiles.isNotEmpty()) {
            writeProfiles(preferences, profiles)
        }
    }

    /**
     * The single writer for the profiles blob. Strips each profile's home cache out into
     * its own key, so this blob stays small enough that the many settings toggles which
     * rewrite it — every one of which rewrites the whole DataStore file — cost nothing
     * proportional to the size of the library.
     *
     * The strip doubles as the migration: an install whose blob still embeds a cache has
     * it moved across the first time anything rewrites the blob, so a profile switch
     * still restores that profile's rows.
     */
    private fun writeProfiles(preferences: MutablePreferences, profiles: List<EmbyProfile>) {
        val stripped = profiles.map { profile ->
            profile.homeCacheJson?.takeIf { it.isNotBlank() }?.let { embedded ->
                profileHomeCacheKey(profile.userId, profile.serverUrl)?.let { key ->
                    if (preferences[key] == null) preferences[key] = embedded
                }
            }
            if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
        }
        preferences[Keys.PROFILES] = settingsJson.encodeToString(stripped)
    }

    /**
     * The launcher's cached rows. Returns the copy decoded by [primeHomeCache] when the
     * JSON has not changed since, so the common path is a reference comparison rather
     * than a parse — see the note on [settingsFlow].
     */
    fun homeCache(settings: Settings): HomeCache? {
        val json = settings.homeCacheJson ?: return null
        decodedHomeCache?.let { (source, decoded) -> if (source == json) return decoded }
        return decodeHomeCache(json).also { decodedHomeCache = json to it }
    }

    private fun primeHomeCache(settings: Settings) {
        val json = settings.homeCacheJson
        if (json == null) {
            decodedHomeCache = null
            return
        }
        if (decodedHomeCache?.first == json) return
        decodedHomeCache = json to decodeHomeCache(json)
    }

    private fun decodeHomeCache(json: String): HomeCache? =
        runCatching { settingsJson.decodeFromString<HomeCache>(json) }.getOrNull()

    /**
     * Reads DataStore directly instead of taking the replayed flow value. This matters
     * immediately after an edit, when the replay slot may still contain the prior value.
     */
    suspend fun snapshot(): Settings = settingsFrom(context.dataStore.data.first())
        .also { latestSettings = it }

    /**
     * Returns the stable device id, deriving and persisting one on first use.
     *
     * This id *is* the television's identity: Emby's devices list and Settings → Devices
     * both key on it, and signing in with one Emby has seen replaces that entry rather
     * than adding a second. So it has to outlive the app's own storage, which on these
     * sets it does not — every APK is sideloaded, an install that will not go over the
     * old one is done by hand as an uninstall and reinstall, and the DataStore's
     * corruption handler replaces the file outright after a process killed mid-write.
     * A random id turned each of those into a second television, one per version, that
     * had to be removed by hand. `ANDROID_ID` survives all of them and is scoped to this
     * app's signing key, so the same set signs back in over its own entry.
     *
     * An id already stored is kept: it is a working identity, and replacing it is the
     * duplicate this exists to avoid.
     */
    suspend fun ensureDeviceId(): String {
        val existing = context.dataStore.data.first()[Keys.DEVICE_ID]
        if (!existing.isNullOrBlank()) return existing
        val androidId = runCatching {
            android.provider.Settings.Secure.getString(
                context.contentResolver,
                android.provider.Settings.Secure.ANDROID_ID,
            )
        }.getOrNull()
        val generated = deviceIdFor(androidId)
        context.dataStore.edit { it[Keys.DEVICE_ID] = generated }
        return generated
    }

    suspend fun saveSession(serverUrl: String, token: String, userId: String, username: String, serverId: String?) {
        context.dataStore.edit { preferences ->
            val profiles = profilesFrom(preferences).toMutableList()
            val id = profileId(serverUrl, userId)
            val previous = profiles.firstOrNull { it.id == id }
            val profile = EmbyProfile(
                id = id,
                serverUrl = serverUrl,
                token = token,
                userId = userId,
                username = username,
                serverId = serverId,
                homeCacheJson = previous?.homeCacheJson,
                forYouMinutes = previous?.forYouMinutes ?: 0,
                hasOpenedForYou = previous?.hasOpenedForYou ?: false,
                welcomeQuoteStyle = previous?.welcomeQuoteStyle
                    ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
                homeSections = previous?.homeSections ?: Settings.DEFAULT_HOME_SECTIONS,
                homeCardDensity = previous?.homeCardDensity ?: Settings.DEFAULT_HOME_CARD_DENSITY,
                homeArtworkStyle = previous?.homeArtworkStyle
                    ?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
                showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
                showRatingsStrip = previous?.showRatingsStrip ?: true,
                hideWatchedMovies = previous?.hideWatchedMovies ?: false,
                homeRowOrder = previous?.homeRowOrder.orEmpty(),
                homePinnedRows = previous?.homePinnedRows.orEmpty(),
                homeHiddenRows = previous?.homeHiddenRows.orEmpty(),
                // Carried over so a re-sign-in on a TV that already knows this profile
                // does not look like a first sync and push stale values back up.
                preferencesRevision = previous?.preferencesRevision ?: 0,
                showTitleLogo = previous?.showTitleLogo ?: true,
                autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true,
                showTenMinuteReminder = previous?.showTenMinuteReminder ?: true,
                subtitlesEnabled = previous?.subtitlesEnabled ?: true,
                subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
                seekIntervalSeconds = previous?.seekIntervalSeconds
                    ?: DEFAULT_SEEK_INTERVAL_SECONDS,
                skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
                speedUpCredits = previous?.speedUpCredits ?: true,
            )
            profiles.removeAll { it.id == id }
            profiles.add(profile)
            writeProfiles(preferences, profiles)
            applyProfile(preferences, profile)
        }
    }

    suspend fun switchProfile(profile: EmbyProfile) {
        context.dataStore.edit { preferences ->
            val profiles = profilesFrom(preferences).toMutableList()
            if (profiles.none { it.id == profile.id }) {
                profiles.add(profile)
                writeProfiles(preferences, profiles)
            }
            applyProfile(preferences, profile)
        }
    }

    /** Forgets a saved profile on this device. */
    suspend fun removeProfile(profileId: String) {
        context.dataStore.edit { preferences ->
            val profiles = profilesFrom(preferences)
            val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit
            writeProfiles(preferences, profiles.filterNot { it.id == profileId })
            // Forget the departing profile's cached rows too; nothing will read that key
            // again and it is the largest single value this store holds.
            profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove)
            if (
                preferences[Keys.USER_ID] == removed.userId &&
                preferences[Keys.SERVER_URL] == removed.serverUrl
            ) {
                clearActiveSession(preferences)
            }
        }
    }

    suspend fun clearSession() {
        context.dataStore.edit {
            clearActiveSession(it)
            // Intentionally keep DEVICE_ID stable across sign-outs.
        }
    }

    /**
     * Drops a server-rejected profile as well as the active session. Keeping the stale
     * profile would let the profile picker immediately restore the same dead token and
     * trap the viewer in a 401 loop.
     */
    suspend fun invalidateActiveSession() {
        context.dataStore.edit { preferences ->
            val activeUserId = preferences[Keys.USER_ID]
            val activeServer = preferences[Keys.SERVER_URL]
            val remaining = profilesFrom(preferences).filterNot {
                it.userId == activeUserId && it.serverUrl == activeServer
            }
            writeProfiles(preferences, remaining)
            profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
            clearActiveSession(preferences)
        }
    }

    private fun clearActiveSession(preferences: MutablePreferences) {
        preferences.remove(Keys.SERVER_URL)
        preferences.remove(Keys.TOKEN)
        preferences.remove(Keys.USER_ID)
        preferences.remove(Keys.SERVER_ID)
        preferences.remove(Keys.LAST_BACKDROP_URL)
        preferences.remove(Keys.HOME_CACHE)
        lastPersistedHomeCache = null
        preferences.remove(Keys.FOR_YOU_MINUTES)
        preferences.remove(Keys.HAS_OPENED_FOR_YOU)
        preferences.remove(Keys.WELCOME_QUOTE_STYLE)
        preferences.remove(Keys.HOME_SECTIONS)
        preferences.remove(Keys.HOME_CARD_DENSITY)
        preferences.remove(Keys.HOME_ARTWORK_STYLE)
        preferences.remove(Keys.SHOW_HOME_CARD_METADATA)
        preferences.remove(Keys.SHOW_RATINGS_STRIP)
        preferences.remove(Keys.HIDE_WATCHED_MOVIES)
        preferences.remove(Keys.HOME_ROW_ORDER)
        preferences.remove(Keys.HOME_PINNED_ROWS)
        preferences.remove(Keys.HOME_HIDDEN_ROWS)
        preferences.remove(Keys.SHOW_TITLE_LOGO)
        preferences.remove(Keys.AUTO_PLAY_NEXT)
        preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER)
        preferences.remove(Keys.SUBTITLES_ENABLED)
        preferences.remove(Keys.SUBTITLE_LANGUAGE)
        preferences.remove(Keys.SEEK_INTERVAL_SECONDS)
        // Cleared with the rest: a revision left behind would make the next profile on
        // this TV believe it had already synced settings it has never seen.
        preferences.remove(Keys.PREFERENCES_REVISION)
        preferences.remove(Keys.USERNAME)
    }

    private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
        preferences[Keys.SERVER_URL] = profile.serverUrl
        preferences[Keys.TOKEN] = profile.token
        preferences[Keys.USER_ID] = profile.userId
        preferences[Keys.USERNAME] = profile.username
        if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
        else preferences[Keys.SERVER_ID] = profile.serverId
        // Prefer the profile's dedicated cache key; fall back to a copy embedded in the
        // profiles blob, which is how installs predating the split stored it — and move
        // that copy across, so the migration completes here too.
        val profileKey = profileHomeCacheKey(profile.userId, profile.serverUrl)
        val cached = profileKey?.let { preferences[it] } ?: profile.homeCacheJson
        if (profileKey != null) {
            if (preferences[profileKey] == null && !cached.isNullOrBlank()) {
                preferences[profileKey] = cached
            }
            // Reads go through [activeHomeCache], which prefers the profile key; keeping a
            // second copy in the flat slot only inflates the file.
            preferences.remove(Keys.HOME_CACHE)
        } else if (cached.isNullOrBlank()) {
            preferences.remove(Keys.HOME_CACHE)
        } else {
            preferences[Keys.HOME_CACHE] = cached
        }
        // The active cache belongs to a different profile now.
        lastPersistedHomeCache = cached
        preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
        preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
        preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle
        preferences[Keys.THEME_ID] = profile.themeId
        // The cached palette belongs to whoever was signed in a moment ago, and this is a
        // different person with a different scheme (and possibly a different allowlist).
        // Dropping it puts this set on the default until ThemeSync answers, which is a
        // second of the app's own colours rather than a minute of somebody else's.
        preferences.remove(Keys.THEME_PALETTE)
        preferences.remove(Keys.THEME_REVISION)
        preferences[Keys.HOME_SECTIONS] = profile.homeSections
        preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
        preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
        preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
        preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip
        preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
        preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
        preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
        preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows
        preferences[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo
        preferences[Keys.AUTO_PLAY_NEXT] = profile.autoPlayNextEpisode
        preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = profile.showTenMinuteReminder
        preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled
        preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
        preferences[Keys.SEEK_INTERVAL_SECONDS] =
            normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
        preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
        preferences[Keys.SPEED_UP_CREDITS] = profile.speedUpCredits
        preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
        preferences.remove(Keys.LAST_BACKDROP_URL)
    }

    private fun legacyProfile(preferences: Preferences): EmbyProfile? {
        val serverUrl = preferences[Keys.SERVER_URL] ?: return null
        val token = preferences[Keys.TOKEN] ?: return null
        val userId = preferences[Keys.USER_ID] ?: return null
        val username = preferences[Keys.USERNAME] ?: return null
        return EmbyProfile(
            id = profileId(serverUrl, userId),
            serverUrl = serverUrl,
            token = token,
            userId = userId,
            username = username,
            serverId = preferences[Keys.SERVER_ID],
            homeCacheJson = activeHomeCache(preferences),
            forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
            hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
            welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
                ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
            homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
            homeCardDensity = preferences[Keys.HOME_CARD_DENSITY]
                ?: Settings.DEFAULT_HOME_CARD_DENSITY,
            homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
                ?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
            showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
            showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true,
            hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
            homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
            homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
            homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
            preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
            showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
            autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
            showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
            subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
            subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
            seekIntervalSeconds = normalizeSeekIntervalSeconds(
                preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
            ),
            skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
            speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
            themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID,
        )
    }

    private fun decodeProfiles(value: String?): List<EmbyProfile> =
        decodeProfilesSafely(value).profiles

    private fun settingsFrom(preferences: Preferences): Settings {
        val storedProfiles = decodeProfiles(preferences[Keys.PROFILES])
        val profiles = if (storedProfiles.isEmpty()) {
            legacyProfile(preferences)?.let(::listOf).orEmpty()
        } else {
            storedProfiles
        }
        return Settings(
            serverUrl = preferences[Keys.SERVER_URL],
            token = preferences[Keys.TOKEN],
            userId = preferences[Keys.USER_ID],
            serverId = preferences[Keys.SERVER_ID],
            username = preferences[Keys.USERNAME],
            deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
            deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
            rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
            updateBaseUrl = preferences[Keys.UPDATE_BASE_URL],
            updateRepo = preferences[Keys.UPDATE_REPO],
            updateToken = preferences[Keys.UPDATE_TOKEN],
            confirmExitMemby = preferences[Keys.CONFIRM_EXIT_MEMBY] ?: false,
            showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
            autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
            showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
            subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
            subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
            seekIntervalSeconds = normalizeSeekIntervalSeconds(
                preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
            ),
            skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
            speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
            audioPassthroughMode = AudioPassthroughMode
                .from(preferences[Keys.AUDIO_PASSTHROUGH_MODE]).value,
            audioPassthroughCodecs = preferences[Keys.AUDIO_PASSTHROUGH_CODECS].orEmpty(),
            ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
            lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
            homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
            homeCacheJson = activeHomeCache(preferences),
            forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
            hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
            homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
            homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
                ?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
            showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
            showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true,
            hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
            homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
            homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
            homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
            welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
                ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
            themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID,
            themePaletteJson = preferences[Keys.THEME_PALETTE],
            themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
            onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
            whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
            requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
            preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
            profiles = profiles,
        )
    }

    private fun profilesFrom(preferences: Preferences): List<EmbyProfile> =
        decodeProfiles(preferences[Keys.PROFILES]).ifEmpty {
            legacyProfile(preferences)?.let(::listOf).orEmpty()
        }

    private fun profileId(serverUrl: String, userId: String): String = "${serverUrl.trimEnd('/')}::$userId"
}

/** The last successful home response, kept locally for instant launcher startup. */
@Serializable
data class HomeCache(
    val continueWatching: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
    /**
     * Only ever read now, never written: a cache from the build that still had two rows is
     * what a TV draws before its first refresh lands, so `HomeUiState.from` folds these
     * into [continueWatching] rather than losing them for that first frame.
     */
    val nextUp: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
    val favorites: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
    val latestMovies: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
    /**
     * Server-composed rows, including recommendations, so a cold start redraws the exact
     * home screen the gateway last sent. Defaulted, so a cache written by an older build
     * still decodes.
     */
    val rows: List<com.ponzischeme89.memby.data.model.HomeRow> = emptyList(),
)
