package com.ponzischeme89.memby.ui.calendar

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.model.GatewayCalendar
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class CalendarUiState(
    val calendar: GatewayCalendar = GatewayCalendar(),
    val selectedDate: String = "",
    val isLoading: Boolean = true,
    val errorMessage: String? = null,
) {
    val weeks: List<List<CalendarCell>> get() = calendarWeeks(calendar)

    /**
     * The focused day's episodes.
     *
     * A blank selection matches nothing rather than matching the first day that also
     * failed to produce a date: [calendarDate] answers with an empty string for a month it
     * cannot read, and without this guard a malformed month would list one arbitrary day's
     * episodes under every cell in the grid.
     */
    val selectedItems get() = if (selectedDate.isEmpty()) {
        emptyList()
    } else {
        calendar.days.firstOrNull {
            calendarDate(calendar.month, it.day) == selectedDate
        }?.items.orEmpty()
    }
}

/**
 * One month at a time, with the months already visited kept in memory.
 *
 * Walking back and forth across a season boundary is the ordinary way this page is used —
 * "when does it come back" is answered by stepping forward and then checking what that did
 * to next week — and re-fetching a month somebody looked at ten seconds ago costs a Sonarr
 * round trip in front of a viewer holding the D-pad.
 */
class CalendarViewModel(
    private val repository: EmbyRepository,
    private val nowMillis: () -> Long = System::currentTimeMillis,
) : ViewModel() {
    private val _state = MutableStateFlow(CalendarUiState())
    val state: StateFlow<CalendarUiState> = _state.asStateFlow()
    private val months = LinkedHashMap<String, GatewayCalendar>()

    /**
     * The device's local day the cache was filled on.
     *
     * The television is not allowed to *lay out* a calendar from its own clock — that is
     * the gateway's answer, and the whole reason the wire carries `firstWeekday`,
     * `dayCount` and `today`. Noticing that midnight has passed is a different question:
     * it decides only whether to ask again, and being wrong about it by an hour costs one
     * request. Without it a set left on this page overnight keeps drawing the ring on
     * yesterday, and every month it had visited stays wrong until the app is restarted.
     */
    private var cachedOnDay: Long = localDay()
    private var loadJob: Job? = null

    /** What was most recently asked for, so a slower earlier answer cannot land after it. */
    private var requestedMonth: String? = null

    init {
        load("")
    }

    /** Moves to a neighbouring month. A blank key is the header's arrow at the end of the range. */
    fun showMonth(month: String) {
        if (month.isBlank()) return
        // Compared against what was *asked for* as well as what is on screen: while a month
        // is in flight the two differ, and without this a viewer holding the D-pad on an
        // arrow re-requests the month already coming.
        if (month == requestedMonth) return
        if (requestedMonth == null && month == _state.value.calendar.month) return
        load(month)
    }

    fun retry() {
        load(requestedMonth ?: _state.value.calendar.month)
    }

    /** Focus is selection here: the panel beside the grid describes whatever cell holds it. */
    fun selectDate(date: String) {
        if (date == _state.value.selectedDate) return
        _state.update { it.copy(selectedDate = date) }
    }

    private fun load(month: String) {
        expireCacheAtMidnight()
        loadJob?.cancel()
        months[month]?.let { cached ->
            requestedMonth = null
            _state.value = CalendarUiState(
                calendar = cached,
                selectedDate = calendarInitialDate(cached),
                isLoading = false,
            )
            return
        }
        requestedMonth = month
        _state.update { it.copy(isLoading = true, errorMessage = null) }
        loadJob = viewModelScope.launch {
            runCatching { repository.getCalendarMonth(month) }
                .onSuccess { calendar ->
                    // Cancelling a job is not the same as stopping it: a coroutine already
                    // past its last suspension point runs this block to the end, and this
                    // block has none. Holding a D-pad on a month arrow is exactly how two
                    // requests come to be in flight, so the answer to a question nobody is
                    // asking any more is dropped rather than drawn over the newer month.
                    if (requestedMonth != month) return@onSuccess
                    requestedMonth = null
                    // Keyed by what was asked for as well as by what came back, so the
                    // opening request — which asks for no month in particular — is not
                    // fetched again the moment somebody steps back to it.
                    cache(month, calendar)
                    cache(calendar.month, calendar)
                    _state.value = CalendarUiState(
                        calendar = calendar,
                        selectedDate = calendarInitialDate(calendar),
                        isLoading = false,
                    )
                }
                .onFailure { error ->
                    if (error is kotlinx.coroutines.CancellationException) throw error
                    if (requestedMonth != month) return@onFailure
                    requestedMonth = null
                    _state.update {
                        it.copy(isLoading = false, errorMessage = friendlyEmbyError(error))
                    }
                }
        }
    }

    private fun cache(key: String, calendar: GatewayCalendar) {
        months[key] = calendar
        // The gateway will only travel [calendarMonthRange] months either way, so this is
        // a ceiling nothing reaches by ordinary use. It is here because "held in memory
        // for the life of the page" and "grows without limit while somebody holds the
        // D-pad" are the same sentence otherwise, and a month is a list of episodes with
        // artwork references, not a small object.
        while (months.size > MAX_CACHED_MONTHS) {
            months.remove(months.keys.first())
        }
    }

    private fun expireCacheAtMidnight() {
        val today = localDay()
        if (today == cachedOnDay) return
        cachedOnDay = today
        months.clear()
    }

    /** Whole local days since the epoch. Only ever compared with itself. */
    private fun localDay(): Long {
        val millis = nowMillis()
        return localEpochDay(millis, java.util.TimeZone.getDefault().getOffset(millis))
    }

    private companion object {
        const val MAX_CACHED_MONTHS = 8
    }
}

class CalendarViewModelFactory(
    private val repository: EmbyRepository,
) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T =
        CalendarViewModel(repository) as T
}
