package com.ponzischeme89.memby.ui.search

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

/**
 * Something to offer when there is nothing to search for yet. Kept deliberately cheap:
 * both kinds are built from data the app already has in memory, because an empty search
 * box is not worth a server request.
 */
data class SearchSuggestion(val label: String, val kind: Kind) {
    enum class Kind { RECENT, GENRE }
}

data class SearchUiState(
    val query: String = "",
    val results: List<BaseItem> = emptyList(),
    /**
     * The genre being browsed, or null when this pane is showing a search.
     *
     * It is a *mode*, not a query: nothing is typed, the keyboard is untouched, and the
     * results are a filtered shelf rather than matches for a word. Keeping it beside the
     * query rather than pretending to be one is what lets the pane say "Comedy" instead of
     * "Search results for “Comedy”", and what lets Back step out of the genre without
     * clearing something the viewer never typed.
     */
    val genre: String? = null,
    /** A further page is on its way. The grid keeps what it has and adds a footer. */
    val isLoadingMore: Boolean = false,
    /** There is more of this genre to ask for. See [hasMoreGenreItems]. */
    val canLoadMore: Boolean = false,
    val suggestions: List<SearchSuggestion> = emptyList(),
    val isLoading: Boolean = false,
    /** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
    val hasSearched: Boolean = false,
    val errorMessage: String? = null,
    val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
    val requestLookupLoading: Boolean = false,
    val requestingCandidateKey: String? = null,
    val requestMessage: String? = null,
    val requestMessageIsError: Boolean = false,
) {
    /** The query is long enough to search but nothing came back. */
    val isEmptyResult: Boolean
        get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()

    /** Nothing typed and no genre open: the pane shows discovery rather than results. */
    val isDiscovery: Boolean
        get() = genre == null && !shouldSearch(query)
}

/**
 * Instant search.
 *
 * Typing feeds [onQueryChanged]; a debounce, a `distinctUntilChanged` and a
 * `collectLatest` do the rest. The last of those is what makes rapid typing safe: it
 * cancels the in-flight request when a newer query arrives, so a slow response for "bre"
 * can never overwrite the results for "break".
 */
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {

    private val _state = MutableStateFlow(SearchUiState())
    val state: StateFlow<SearchUiState> = _state.asStateFlow()

    private val queryFlow = MutableStateFlow("")

    /**
     * Results for queries typed earlier in this session. Bounded and access-ordered, so
     * backspacing through a word redraws instantly instead of re-querying every prefix.
     */
    private val cache = object : LinkedHashMap<String, List<BaseItem>>(16, 0.75f, true) {
        override fun removeEldestEntry(
            eldest: MutableMap.MutableEntry<String, List<BaseItem>>?,
        ): Boolean = size > CACHE_ENTRIES
    }

    private val recentQueries = ArrayDeque<String>()
    private var genreSuggestions: List<String> = emptyList()

    /**
     * The page in flight, so a viewer who leaves a genre — or scrolls a second page while
     * the first is still coming — is never overtaken by an answer to a question they have
     * moved on from. It is the genre shelf's equivalent of the search pipeline's
     * `collectLatest`.
     */
    private var genrePageJob: Job? = null

    init {
        viewModelScope.launch {
            queryFlow
                .debounce(DEBOUNCE_MS)
                .map { it.trim() }
                .distinctUntilChanged()
                // collectLatest rather than flatMapLatest: the search is a one-shot
                // suspend call, not a flow, and this cancels the previous one on the
                // same terms while keeping the repository's plain suspend signature.
                .collectLatest { term -> runSearch(term) }
        }
        viewModelScope.launch {
            repository.getRecentSearches().forEach { term ->
                if (recentQueries.none { it.equals(term, ignoreCase = true) }) {
                    recentQueries.addLast(term)
                }
            }
            while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
            refreshSuggestions()
        }
        refreshSuggestions()
    }

    /** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
    fun onQueryChanged(query: String) {
        // Typing supersedes a genre. Somebody who reaches for the keyboard while a shelf is
        // open is asking for something else, and a pane headed "Comedy" listing matches for
        // what they are typing would be a lie about where those results came from.
        genrePageJob?.cancel()
        // The visible field updates immediately; only the *search* is debounced.
        _state.update {
            it.copy(
                query = query,
                genre = null,
                isLoadingMore = false,
                canLoadMore = false,
                requestCandidates = emptyList(),
                requestLookupLoading = false,
                requestMessage = null,
                requestMessageIsError = false,
            )
        }
        queryFlow.value = query
    }

    fun appendToQuery(text: String) = onQueryChanged(state.value.query + text)

    fun backspace() = onQueryChanged(state.value.query.dropLast(1))

    fun clearQuery() {
        // Clear immediately so results from a genre tile cannot remain visible while the
        // debounced empty-query transition is pending.
        genrePageJob?.cancel()
        _state.update {
            it.copy(
                query = "", results = emptyList(), isLoading = false, hasSearched = false,
                errorMessage = null, requestCandidates = emptyList(),
                requestLookupLoading = false, requestMessage = null,
                requestMessageIsError = false, genre = null,
                isLoadingMore = false, canLoadMore = false,
            )
        }
        queryFlow.value = ""
    }

    /**
     * A genre chip was pressed. This is a filter, not a query: nothing is typed and nothing
     * is debounced — the viewer made one deliberate choice and the shelf opens on it.
     */
    fun onGenreSelected(genre: String) {
        val name = genre.trim()
        if (name.isEmpty()) return
        genrePageJob?.cancel()
        _state.update {
            it.copy(
                query = "", genre = name, results = emptyList(), isLoading = true,
                hasSearched = false, errorMessage = null, isLoadingMore = false,
                canLoadMore = false, requestCandidates = emptyList(),
                requestLookupLoading = false, requestMessage = null,
                requestMessageIsError = false,
            )
        }
        // The typed query is dropped along with it, and the flow is told so a stale term
        // cannot arrive from the debounce and overwrite the shelf that is opening.
        queryFlow.value = ""
        genrePageJob = viewModelScope.launch { loadGenrePage(name, offset = 0) }
    }

    /** Back out of a genre, to the discovery pane the chip was pressed on. */
    fun clearGenre() {
        genrePageJob?.cancel()
        _state.update {
            it.copy(
                genre = null, results = emptyList(), isLoading = false, hasSearched = false,
                errorMessage = null, isLoadingMore = false, canLoadMore = false,
            )
        }
    }

    /**
     * The grid is near the end of what it holds. Ignored unless there is a genre open, more
     * of it to fetch and nothing already in flight — the grid asks on every scroll, and it
     * is cheaper to refuse here than to make the screen keep track.
     */
    fun loadMore() {
        val current = state.value
        val genre = current.genre ?: return
        if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return
        _state.update { it.copy(isLoadingMore = true) }
        genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.results.size) }
    }

    /** Retry after an error, without disturbing the query or the keyboard. */
    fun retry() {
        val current = state.value
        current.genre?.let { genre ->
            genrePageJob?.cancel()
            _state.update { it.copy(isLoading = true, errorMessage = null) }
            genrePageJob = viewModelScope.launch {
                loadGenrePage(genre, offset = current.results.size)
            }
            return
        }
        val term = current.query.trim()
        if (!shouldSearch(term)) return
        cache.remove(term)
        viewModelScope.launch { runSearch(term) }
    }

    fun request(candidate: GatewayRequestCandidate) {
        if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
        val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
        _state.update {
            it.copy(requestingCandidateKey = candidateKey, requestMessage = null, requestMessageIsError = false)
        }
        viewModelScope.launch {
            runCatching { repository.requestMedia(candidate) }
                .onSuccess { title ->
                    _state.update {
                        it.copy(
                            requestingCandidateKey = null,
                            requestCandidates = it.requestCandidates.map { option ->
                                if (option.mediaType == candidate.mediaType &&
                                    option.foreignId == candidate.foreignId
                                ) option.copy(alreadyAdded = true) else option
                            },
                            requestMessage = "${title.ifBlank { candidate.title }} was requested.",
                            requestMessageIsError = false,
                        )
                    }
                }
                .onFailure { error ->
                    _state.update {
                        it.copy(
                            requestingCandidateKey = null,
                            requestMessage = friendlyEmbyError(error),
                            requestMessageIsError = true,
                        )
                    }
                }
        }
    }

    /**
     * Genre chips for the empty state, taken from items the home screen already loaded.
     * Nothing is fetched: if home has no data yet, the chips simply do not appear.
     */
    fun setDiscoveryItems(items: List<BaseItem>) {
        val genres = items.asSequence()
            .flatMap { it.genres.asSequence() }
            .map { it.trim() }
            .filter { it.isNotEmpty() }
            .groupingBy { it }
            .eachCount()
            .entries
            .sortedByDescending { it.value }
            .take(MAX_GENRE_SUGGESTIONS)
            .map { it.key }
        if (genres != genreSuggestions) {
            genreSuggestions = genres
            refreshSuggestions()
        }
    }

    /**
     * One page of a genre, appended to whatever the shelf already holds.
     *
     * Appending by [GenrePage.offset] rather than trusting the order of arrival is what
     * makes a slow page harmless: only a page that starts where the shelf currently ends is
     * taken, so a response for an offset the viewer has already scrolled past — or one from
     * a genre they have left — is dropped rather than pasted into the middle of the grid.
     */
    private suspend fun loadGenrePage(genre: String, offset: Int) {
        runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) }
            .onSuccess { page ->
                _state.update { current ->
                    if (current.genre != genre || current.results.size != page.offset) return@update current
                    val items = current.results + page.items
                    current.copy(
                        results = items,
                        isLoading = false,
                        isLoadingMore = false,
                        hasSearched = true,
                        errorMessage = null,
                        canLoadMore = hasMoreGenreItems(
                            loaded = items.size,
                            total = page.total,
                            lastPageSize = page.items.size,
                            pageSize = GENRE_PAGE_SIZE,
                        ),
                    )
                }
            }
            .onFailure { error ->
                if (error is kotlinx.coroutines.CancellationException) throw error
                _state.update { current ->
                    if (current.genre != genre) return@update current
                    current.copy(
                        isLoading = false,
                        isLoadingMore = false,
                        hasSearched = true,
                        // A page that failed part way down a shelf keeps what is already
                        // there and simply stops: the viewer has plenty on screen, and
                        // replacing it with an error would take away what was working.
                        errorMessage = if (current.results.isEmpty()) friendlyEmbyError(error) else null,
                        canLoadMore = false,
                    )
                }
            }
    }

    private suspend fun runSearch(term: String) {
        // A genre shelf is not a query, so the empty-query transition has nothing to say
        // about it. Without this, opening a genre — which clears the query — would arrive
        // here a moment later and wipe the shelf it had just filled.
        if (state.value.genre != null && !shouldSearch(term)) return
        if (!shouldSearch(term)) {
            // Back to the discovery state, but the previous results are dropped rather
            // than left behind a shorter query they no longer match.
            _state.update {
                it.copy(
                    results = emptyList(), isLoading = false, hasSearched = false,
                    errorMessage = null, requestCandidates = emptyList(),
                    requestLookupLoading = false, requestMessage = null,
                    requestMessageIsError = false,
                )
            }
            return
        }

        cache[term]?.let { cached ->
            _state.update {
                it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
            }
            if (cached.isEmpty()) loadRequestCandidates(term)
            return
        }

        // Previous results stay on screen underneath the spinner: a flash of empty pane
        // between two letters reads as breakage, not as progress.
        _state.update { it.copy(isLoading = true, errorMessage = null) }
        runCatching { repository.search(term) }
            .onSuccess { items ->
                // Gateway payloads carry the backend ranker's score. Preserve that
                // ordering exactly; direct-to-Emby mode keeps the local textual fallback.
                val ranked = if (items.any { it.membyRecommendationScore != null }) {
                    items
                } else {
                    rankSearchResults(term, items)
                }
                cache[term] = ranked
                rememberQuery(term)
                viewModelScope.launch { repository.recordSearch(term) }
                _state.update {
                    it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
                }
                if (ranked.isEmpty()) loadRequestCandidates(term)
            }
            .onFailure { error ->
                // A cancelled search is the normal case while typing, not a failure.
                if (error is kotlinx.coroutines.CancellationException) throw error
                _state.update {
                    it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
                }
            }
    }

    private suspend fun loadRequestCandidates(term: String) {
        _state.update { it.copy(requestLookupLoading = true) }
        val candidates = runCatching { repository.lookupMediaRequests(term) }
            .getOrElse { error ->
                if (error is kotlinx.coroutines.CancellationException) throw error
                emptyList()
            }
        if (_state.value.query.trim() == term) {
            _state.update {
                it.copy(requestCandidates = candidates, requestLookupLoading = false)
            }
        }
    }

    private fun rememberQuery(term: String) {
        recentQueries.removeAll { it.equals(term, ignoreCase = true) }
        recentQueries.addFirst(term)
        while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
        refreshSuggestions()
    }

    private fun refreshSuggestions() {
        val suggestions = recentQueries.map { SearchSuggestion(it, SearchSuggestion.Kind.RECENT) } +
            genreSuggestions.map { SearchSuggestion(it, SearchSuggestion.Kind.GENRE) }
        _state.update { it.copy(suggestions = suggestions) }
    }

    companion object {
        const val DEBOUNCE_MS = 250L
        const val MIN_QUERY_LENGTH = 2
        private const val CACHE_ENTRIES = 24
        private const val MAX_RECENT_QUERIES = 6
        private const val MAX_GENRE_SUGGESTIONS = 6
    }
}

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

/**
 * Two characters, not one.
 *
 * A single letter matches a large fraction of any library, so the request is slow, the
 * results are noise, and it fires on the way to every real query. Multi-word queries are
 * untouched — only the trimmed length matters.
 */
fun shouldSearch(query: String): Boolean = query.trim().length >= SearchViewModel.MIN_QUERY_LENGTH

/**
 * Orders results so the obvious answer is first.
 *
 * The backend already sorts by its own relevance (Postgres `ts_rank`, or Emby's own
 * ordering), and that ordering is *preserved within each tier* — this only lifts the
 * matches a person would be annoyed to find below the fold. Sorting is stable, so a
 * backend that already got it right is not reshuffled.
 */
fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
    val term = query.trim().lowercase()
    if (term.isEmpty()) return items
    return items.sortedBy { item -> matchTier(term, item) }
}

private fun matchTier(term: String, item: BaseItem): Int {
    val name = item.name.trim().lowercase()
    val series = item.seriesName.orEmpty().trim().lowercase()
    return when {
        name == term -> 0
        series == term -> 1
        name.startsWith(term) -> 2
        series.startsWith(term) -> 3
        name.containsWordStartingWith(term) -> 4
        name.contains(term) -> 5
        series.contains(term) -> 6
        // Everything else the backend returned: genre, year or overview matches. Kept,
        // because "no exact match but here is the related thing" beats an empty pane.
        else -> 7
    }
}

/** "star" should rank higher in "Lone Star" than in "Costar". */
private fun String.containsWordStartingWith(term: String): Boolean =
    split(' ', '-', ':', '.', '\'').any { it.startsWith(term) }
