package com.ponzischeme89.memby.data

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayTheme
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json

/**
 * Keeps this television painted the colour the gateway says it should be.
 *
 * Three things move a theme and all three arrive the same way — as a revision on the status
 * poll that no longer matches what this set holds:
 *
 * - **The viewer picks a scheme.** The choice is an ordinary synced setting, pushed by
 *   [PreferencesSync]; the *palette* comes back through here a moment later.
 * - **The operator changes what they may choose.** A scheme withdrawn resolves to the
 *   default, and the set repaints without anybody signing in again.
 * - **A season begins or ends.** This is the one that could not work any other way. Nobody
 *   writes anything at midnight on the 1st of December — the answer simply becomes
 *   different — which is why the revision is a hash of the resolved theme rather than a
 *   counter in a table, and why the poll is where it rides.
 *
 * The cached palette is applied first, before any request. A television that has been signed
 * in before therefore starts in its own colours rather than opening in the default and
 * flicking over a second later, which is the same promise `HomeCache` makes about the rows.
 *
 * Everything here fails silently. A colour is not worth an error message on a television,
 * and the state a failure leaves — the palette already on screen, the revision not advanced
 * — is a correct one to sit in until the next poll.
 */
class ThemeSync(
    private val repository: EmbyRepository,
    private val settings: SettingsStore,
    private val remoteTheme: StateFlow<GatewayThemeStatus>,
    private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
    private val json = Json { ignoreUnknownKeys = true }

    private val _theme = MutableStateFlow<GatewayTheme?>(null)

    /**
     * The resolved theme as the server last described it: which scheme is on, whether it is
     * a season, and the sentence saying so. Null until this set has been told once — which
     * is what the settings picker renders as "not available" rather than as a locked state
     * it has no evidence for.
     */
    val theme: StateFlow<GatewayTheme?> = _theme.asStateFlow()

    private val _available = MutableStateFlow<List<GatewayTheme>>(emptyList())

    /**
     * The schemes this viewer may choose between. Empty on the direct path and before the
     * first fetch, and the picker draws nothing rather than a list compiled into the APK:
     * the per-user allowlist is only real if a withheld theme is one the television was
     * never sent.
     */
    val available: StateFlow<List<GatewayTheme>> = _available.asStateFlow()

    /** One fetch at a time; two racing would decide the stored revision twice. */
    private val mutex = Mutex()

    /**
     * The revision whose palette is on screen. Held here as well as on disk because the
     * write is skipped when nothing changed, so disk alone cannot say whether this process
     * has already acted on a revision.
     */
    private var appliedRevision: String? = null

    init {
        scope.launch { run() }
    }

    private suspend fun run() {
        // Paint from the cache immediately, outside the lifecycle gate below: this has to
        // happen while the launcher is composing its first frame, not when the process
        // happens to reach the foreground.
        scope.launch {
            repository.settingsFlow
                .distinctUntilChanged { old, new -> old.themePaletteJson == new.themePaletteJson }
                .collect { session -> applyCached(session.themePaletteJson) }
        }

        ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            combine(repository.settingsFlow, remoteTheme) { session, status ->
                SyncTrigger(session, status)
            }
                .distinctUntilChanged()
                // Plain collect rather than collectLatest: a fetch cancelled halfway could
                // leave the stored revision describing a palette that was never written.
                .collect(::reconcile)
        }
    }

    /** The only parts of the session and the poll a theme fetch depends on. */
    private data class SyncTrigger(
        val signedIn: Boolean,
        val heldRevision: String,
        val serverRevision: String,
    ) {
        constructor(session: Settings, status: GatewayThemeStatus) : this(
            signedIn = session.isSignedIn,
            heldRevision = session.themeRevision,
            serverRevision = status.revision,
        )
    }

    private suspend fun reconcile(trigger: SyncTrigger) {
        if (!ServerConfig.isGateway) return
        if (!trigger.signedIn) {
            // Back to the app's own colours. A sign-out that left the last viewer's scheme
            // on the setup screen would be showing somebody's choice to whoever is about to
            // replace them.
            _theme.value = null
            _available.value = emptyList()
            appliedRevision = null
            applyMembyPalette(MembyPalette())
            return
        }
        // A server that predates themes sends nothing, and there is nothing to fetch. The
        // palette already on screen — cached or default — is the right thing to keep.
        if (trigger.serverRevision.isEmpty()) return
        if (trigger.serverRevision == appliedRevision &&
            trigger.serverRevision == trigger.heldRevision
        ) {
            return
        }
        mutex.withLock { fetch(trigger.serverRevision) }
    }

    private suspend fun fetch(expectedRevision: String) {
        if (appliedRevision == expectedRevision) return
        val document = runCatching { repository.theme() }.getOrNull() ?: return
        val resolved = document.theme
        _theme.value = resolved
        _available.value = document.available

        val palette = resolved.palette.toMembyPalette()
        applyMembyPalette(palette)
        // The revision recorded is the one the *response* carried, not the one the poll
        // advertised. They differ if a season turned over between the two, and storing the
        // poll's would leave this set believing it holds a palette it never received.
        appliedRevision = resolved.revision.ifEmpty { expectedRevision }
        runCatching {
            settings.setThemePalette(
                json.encodeToString(com.ponzischeme89.memby.data.model.GatewayPalette.serializer(), resolved.palette),
                appliedRevision.orEmpty(),
            )
        }.onFailure {
            // The palette is on screen and simply not cached: this set repaints correctly
            // now and pays one extra fetch on its next cold start. Clearing the applied
            // revision would instead make it refetch on every poll.
        }
    }

    /**
     * Paints from what was stored at the end of the last session, before anything is asked
     * of the network. A blank or unreadable cache leaves the default palette standing.
     */
    private fun applyCached(paletteJson: String?) {
        if (paletteJson.isNullOrBlank()) return
        val palette = runCatching {
            json.decodeFromString(
                com.ponzischeme89.memby.data.model.GatewayPalette.serializer(),
                paletteJson,
            )
        }.getOrNull() ?: return
        applyMembyPalette(palette.toMembyPalette())
    }
}
