package com.ponzischeme89.memby.data

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayAlert
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

data class MaintenanceNotice(val message: String)
data class CompatibilityNotice(val message: String)

/**
 * Emby is not answering, and something is still trying.
 *
 * Distinct from [MaintenanceNotice], which is Memby being taken down deliberately: this is
 * the media server behind it going quiet. It matters to the viewer for one specific
 * reason — video direct-plays from Emby, so during an outage a film stops with no
 * explanation at all, while Memby itself stays up and can say why.
 *
 * [nextAttemptAtMillis] is elapsed-realtime, not wall clock, so the countdown cannot be
 * thrown by a TV correcting its clock over the network mid-outage.
 */
data class EmbyOutage(
    val nextAttemptAtMillis: Long,
    val retryIntervalSeconds: Int,
)

/**
 * One informational banner: a show aired, a film was added, the library finished
 * refreshing, the server stopped answering. It is never actionable and never focusable —
 * it slides in, says its piece and goes, over the launcher or over playback alike.
 *
 * [label] is the eyebrow above the title and comes from the server, so a kind of news
 * this build has never heard of still reads correctly; a server that sends none gets the
 * original wording back.
 */
data class ServiceAlert(
    val id: String,
    val title: String,
    val message: String,
    val label: String = "",
)

/**
 * One process-wide control channel shared by every Memby activity.
 *
 * A network failure leaves the last confirmed state intact: losing connectivity while a
 * maintenance notice is showing must not briefly reopen playback. A successful status
 * response is the only thing that enters or clears maintenance.
 *
 * The same poll carries alerts, so news reaches an open app without a second connection.
 * The gateway keeps offering an alert for as long as it is current and has no idea which
 * TVs have seen it, so *this* side owns "shown already" — persisted, or every relaunch
 * would replay yesterday's news.
 *
 * The loop runs only while a Memby screen is in the foreground, and an alert counts as
 * shown only when the banner says so ([alertShown]). Both exist for the same reason: work
 * done for a screen nobody is looking at is worse than wasted, because it also burns the
 * one chance to deliver the news.
 */
class MaintenanceMonitor(
    private val repository: EmbyRepository,
    private val settings: SettingsStore,
) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private val _notice = MutableStateFlow<MaintenanceNotice?>(null)
    val notice: StateFlow<MaintenanceNotice?> = _notice.asStateFlow()

    private val _compatibility = MutableStateFlow<CompatibilityNotice?>(null)
    val compatibility: StateFlow<CompatibilityNotice?> = _compatibility.asStateFlow()

    private val _alert = MutableStateFlow<ServiceAlert?>(null)
    val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()

    private val _embyOutage = MutableStateFlow<EmbyOutage?>(null)

    /** Null whenever Emby is answering, or when nothing is watching it. */
    val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()

    private val _preferencesRevision = MutableStateFlow(0L)
    private val _theme = MutableStateFlow(GatewayThemeStatus())
    private val _installPermissionPrompt = MutableStateFlow(false)
    private val _genreBrowserEnabled = MutableStateFlow(false)
    private val _tvCalendarEnabled = MutableStateFlow(false)
    private val _gatewayVersion = MutableStateFlow("")

    /**
     * Which colour scheme this viewer's televisions should be painted, as an id and a
     * revision. [ThemeSync] fetches the palette only when the revision moves.
     *
     * It rides this poll rather than the sign-in because that is the whole feature: a
     * seasonal theme has to reach a set that is already switched on, at the midnight it
     * begins, with nobody doing anything.
     */
    val theme: StateFlow<GatewayThemeStatus> = _theme.asStateFlow()

    /**
     * The viewer's server-held settings revision, as of the last successful poll. This is
     * how an operator's push reaches a television: the number changes, [PreferencesSync]
     * notices and fetches the document. Carrying the revision rather than the settings
     * themselves keeps a poll that runs every ten seconds on every open TV to one integer.
     */
    val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()

    /**
     * Whether the operator wants TVs that cannot install their own updates to be asked for
     * the permission. Pushed through the same poll as everything else, and false whenever
     * the server has not said otherwise — a screen that appears because a field was missing
     * would be the wrong way round.
     */
    val installPermissionPrompt: StateFlow<Boolean> = _installPermissionPrompt.asStateFlow()

    /** Server-controlled because the browser layout is still being refined. */
    val genreBrowserEnabled: StateFlow<Boolean> = _genreBrowserEnabled.asStateFlow()

    /**
     * Whether the TV calendar destination belongs on the rail. False whenever the server has
     * not said otherwise, which is what keeps the entry off a household running no Sonarr:
     * a rail item leading to a page with nothing behind it is worse than no item at all.
     */
    val tvCalendarEnabled: StateFlow<Boolean> = _tvCalendarEnabled.asStateFlow()

    /** Build reported by the connected gateway, for Settings → About. */
    val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()

    /**
     * Alerts this television has already shown.
     *
     * Bounded, and by rather more than the persisted list keeps: what is on disk is the
     * record that has to survive a relaunch, while this one only has to outlive the
     * gateway's own alert window. Unbounded it grows by an entry per announcement for as
     * long as the process lives, and a television in a living room is a process that lives
     * for months.
     */
    private val seenAlertIds = java.util.Collections.newSetFromMap(
        object : LinkedHashMap<String, Boolean>(MAX_TRACKED_ALERT_IDS, 0.75f, false) {
            override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>?): Boolean =
                size > MAX_TRACKED_ALERT_IDS
        },
    )
    private var seenAlertsLoaded = false
    private var shownAlertId: String? = null
    private var alertTimer: Job? = null

    init {
        scope.launchStatusLoop()
        scope.launchDirectEmbyProbe()
    }

    /**
     * Called by the banner once it is actually on screen. Until this arrives the alert is
     * only *offered*: nothing is persisted and no timer runs, so an alert that lands while
     * the screensaver is up or another app is in front survives to be shown later rather
     * than being consumed by nobody.
     *
     * Seen is recorded on display rather than on dismissal, because a banner interrupted
     * by a crash or a power cut is not worth replaying a day later.
     */
    fun alertShown(id: String) {
        if (shownAlertId == id) return
        shownAlertId = id
        seenAlertIds += id
        scope.launch { runCatching { settings.markAlertSeen(id) } }

        alertTimer?.cancel()
        alertTimer = scope.launch {
            delay(ALERT_VISIBLE_MS)
            if (_alert.value?.id == id) _alert.value = null
        }
    }

    /** Hides the current banner early — the viewer has already read it. */
    fun dismissAlert() {
        alertTimer?.cancel()
        shownAlertId = null
        _alert.value = null
    }

    private fun CoroutineScope.launchStatusLoop() = launch {
        // Only while a Memby screen is in front. Backgrounded, this loop is cancelled
        // outright rather than left ticking a request every ten seconds at a TV nobody
        // is looking at; coming back to the foreground restarts it with an immediate
        // poll, so maintenance is still caught the moment the viewer returns.
        ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            repository.settingsFlow.collectLatest { session ->
                if (!ServerConfig.isGateway || !session.isSignedIn) {
                    _notice.value = null
                    _compatibility.value = null
                    // The direct-path probe owns the outage bar when there is no gateway;
                    // clearing it here would fight that loop for the same flow.
                    if (ServerConfig.isGateway) _embyOutage.value = null
                    _preferencesRevision.value = 0
                    _theme.value = GatewayThemeStatus()
                    _installPermissionPrompt.value = false
                    _genreBrowserEnabled.value = false
                    _tvCalendarEnabled.value = false
                    _gatewayVersion.value = ""
                    dismissAlert()
                    return@collectLatest
                }

                while (isActive) {
                    var nextPollDelayMs = POLL_INTERVAL_MS
                    runCatching { repository.serviceStatus() }
                        .onSuccess { status ->
                            _compatibility.value = if (status.compatible) {
                                null
                            } else {
                                CompatibilityNotice(
                                    status.compatibilityMessage.trim().ifEmpty {
                                        "This Memby app and server are not compatible. Update the app or contact the server administrator."
                                    },
                                )
                            }
                            _notice.value = if (status.maintenance) {
                                MaintenanceNotice(
                                    status.message.trim().ifEmpty {
                                        "Memby is down for maintenance. Try again shortly."
                                    },
                                )
                            } else {
                                null
                            }
                            _preferencesRevision.value = status.preferencesRevision
                            _theme.value = status.theme
                            _installPermissionPrompt.value =
                                status.features[INSTALL_PERMISSION_FEATURE] == true
                            _genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
                            _tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true
                            _gatewayVersion.value = status.gatewayVersion
                            // Emby's state is reported even during maintenance: an
                            // operator taking Memby down while Emby is also unreachable
                            // should not have that fact disappear from the poll.
                            _embyOutage.value = outageFrom(status.emby)
                            if (status.maintenance) {
                                // The maintenance screen owns the display; anything
                                // cheerful in front of it would only be confusing.
                                dismissAlert()
                            } else {
                                offerNextAlert(status.alerts)
                            }
                        }
                        .onFailure { error ->
                            if (error is retrofit2.HttpException && error.code() == 429) {
                                nextPollDelayMs = error.response()?.headers()?.get("Retry-After")
                                    ?.trim()?.toLongOrNull()
                                    ?.coerceIn(1L, MAX_RATE_LIMIT_SECONDS)
                                    ?.times(1_000L)
                                    ?: RATE_LIMIT_FALLBACK_MS
                            }
                            if (isUnauthorizedError(error)) {
                                repository.invalidateSession()
                                _notice.value = null
                                _compatibility.value = null
                                _embyOutage.value = null
                                _preferencesRevision.value = 0
                                _theme.value = GatewayThemeStatus()
                                _installPermissionPrompt.value = false
                                _genreBrowserEnabled.value = false
                                _tvCalendarEnabled.value = false
                                _gatewayVersion.value = ""
                                dismissAlert()
                                return@collectLatest
                            }
                        }
                    delay(nextPollDelayMs)
                }
            }
        }
    }

    /**
     * Turns the gateway's reading into a countdown this TV can render.
     *
     * The next attempt is computed from *this device's* elapsed clock rather than from the
     * server's timestamps, because the two are not the same clock and the difference would
     * show up as a countdown that jumps. `checkedAt` is deliberately unused for that
     * reason: it says when the probe ran on the gateway, which is only a lower bound on
     * when this poll heard about it.
     */
    private fun outageFrom(health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth): EmbyOutage? =
        nextOutageState(health, _embyOutage.value, elapsedRealtime())

    /**
     * The direct path's own probe. With no gateway there is nobody to ask, so the app asks
     * Emby itself — on the same minute the gateway would have used, so the bar reads
     * identically either way.
     *
     * It runs only while signed in and only on the direct path, and only in the
     * foreground: a background loop hitting a server every minute for a screen nobody is
     * looking at is exactly what the status poll already refuses to do.
     */
    private fun CoroutineScope.launchDirectEmbyProbe() = launch {
        if (ServerConfig.isGateway) return@launch
        ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            repository.settingsFlow.collectLatest { session ->
                if (!session.isSignedIn) {
                    _embyOutage.value = null
                    return@collectLatest
                }
                var failures = 0
                while (isActive) {
                    if (repository.pingEmby()) {
                        failures = 0
                        _embyOutage.value = null
                    } else {
                        failures++
                        // Same threshold the gateway applies, and for the same reason:
                        // one failed request is a hiccup, and a red bar for it is worse
                        // than a moment of silence. Once past it the countdown is re-armed
                        // on every attempt, because the next attempt really is a minute
                        // from now.
                        if (failures >= DIRECT_OUTAGE_THRESHOLD) {
                            _embyOutage.value = EmbyOutage(
                                nextAttemptAtMillis =
                                    elapsedRealtime() + DEFAULT_RETRY_SECONDS * 1000L,
                                retryIntervalSeconds = DEFAULT_RETRY_SECONDS,
                            )
                        }
                    }
                    delay(DEFAULT_RETRY_SECONDS * 1000L)
                }
            }
        }
    }

    private fun elapsedRealtime(): Long = android.os.SystemClock.elapsedRealtime()

    private suspend fun offerNextAlert(alerts: List<GatewayAlert>) {
        if (!seenAlertsLoaded) {
            seenAlertIds += runCatching { settings.seenAlertIds() }.getOrDefault(emptySet())
            seenAlertsLoaded = true
        }

        val pending = _alert.value
        if (pending != null) {
            if (pendingAlertExpired(pending.id, shownAlertId, alerts)) {
                _alert.value = null
            }
            // One at a time either way; a queued second alert is still unseen next poll.
            return
        }

        val next = firstUnseenAlert(alerts, seenAlertIds) ?: return
        _alert.value = ServiceAlert(
            id = next.id,
            title = next.title.trim(),
            message = next.message.trim(),
            // The banner shows the Emby mark rather than item artwork — half these
            // alerts (a refresh, an outage) have no artwork — so the itemId and
            // imageTag the gateway still sends are deliberately unused here.
            label = next.label.trim(),
        )
    }

    companion object {
        internal const val POLL_INTERVAL_MS = 10_000L
        internal const val RATE_LIMIT_FALLBACK_MS = 60_000L
        internal const val MAX_RATE_LIMIT_SECONDS = 3_600L

        /** Matches `featureInstallPermission` in the gateway's feature catalogue. */
        internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt"

        /** Matches `featureGenreBrowser` in the gateway's feature catalogue. */
        internal const val GENRE_BROWSER_FEATURE = "genre_browser"
        internal const val TV_CALENDAR_FEATURE = "tv_calendar"

        /** Insertion-ordered ceiling on the in-memory seen set. See [seenAlertIds]. */
        private const val MAX_TRACKED_ALERT_IDS = 200

        /**
         * How often Emby is retried during an outage. It matches the gateway's own
         * default (MEMBY_EMBY_HEALTH_INTERVAL), so the countdown on the bar means the
         * same thing whether the probe is running here or there.
         */
        internal const val DEFAULT_RETRY_SECONDS = 60

        /** Consecutive direct-path failures before the bar appears. */
        internal const val DIRECT_OUTAGE_THRESHOLD = 2

        /**
         * How long one banner stays on screen. The banner draws a ring counting this
         * down, so the two must agree — take the duration from here rather than
         * hard-coding it in the UI.
         */
        const val ALERT_VISIBLE_MS = 10_000L
    }
}

/**
 * The outage state to publish after a status poll, given the one already showing.
 *
 * The rule worth keeping is that an outage already on screen keeps its countdown until it
 * actually runs out. The status poll is six times faster than the retry it is describing,
 * so recomputing the deadline every time would reset the number every ten seconds and the
 * viewer would watch a counter that never reaches zero.
 *
 * Pure, and takes [nowMillis] as a parameter, so the two properties that matter — a live
 * countdown survives a poll, an expired one is re-armed — can be tested without waiting a
 * minute for each.
 */
internal fun nextOutageState(
    health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth,
    existing: EmbyOutage?,
    nowMillis: Long,
): EmbyOutage? {
    if (!health.isOutage) return null
    if (existing != null && existing.nextAttemptAtMillis > nowMillis) return existing
    val retrySeconds = health.retrySeconds.takeIf { it > 0 }
        ?: MaintenanceMonitor.DEFAULT_RETRY_SECONDS
    return EmbyOutage(
        nextAttemptAtMillis = nowMillis + retrySeconds * 1000L,
        retryIntervalSeconds = retrySeconds,
    )
}

/**
 * Whether an alert held for a screen that never appeared should be given up on.
 *
 * An offered alert waits indefinitely for a foreground banner, so something has to end
 * that wait: once the gateway stops listing it, the episode aired long enough ago that
 * announcing it is no longer news. An alert already shown is left alone — its own
 * dismissal timer owns it.
 */
internal fun pendingAlertExpired(
    pendingId: String,
    shownId: String?,
    alerts: List<GatewayAlert>,
): Boolean = pendingId != shownId && alerts.none { it.id == pendingId }

/**
 * The first alert this TV has not shown before. Alerts arrive newest-first, and anything
 * missing an id, a title or a message is dropped rather than rendered as an empty card.
 */
internal fun firstUnseenAlert(alerts: List<GatewayAlert>, seen: Set<String>): GatewayAlert? =
    alerts.firstOrNull { alert ->
        alert.id.isNotBlank() &&
            alert.id !in seen &&
            alert.title.isNotBlank() &&
            alert.message.isNotBlank()
    }
