/* Copyright 2022 Tusky Contributors
 *
 * This file is a part of Tusky.
 *
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 3 of the
 * License, or (at your option) any later version.
 *
 * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
 * Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with Tusky; if not,
 * see <http://www.gnu.org/licenses>. */

package com.keylesspalace.tusky.components.viewthread

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import at.connyduck.calladapter.networkresult.getOrThrow
import at.connyduck.calladapter.networkresult.map
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusChangedEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.components.timeline.toViewData
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import com.squareup.moshi.Moshi
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class ViewThreadViewModel @Inject constructor(
    private val api: MastodonApi,
    private val filterModel: FilterModel,
    private val timelineCases: TimelineCases,
    eventHub: EventHub,
    private val accountManager: AccountManager,
    private val db: AppDatabase,
    private val moshi: Moshi
) : ViewModel() {

    private val _uiState = MutableStateFlow(ThreadUiState.Loading as ThreadUiState)
    val uiState: Flow<ThreadUiState> = _uiState.asStateFlow()

    private val _errors = MutableSharedFlow<Throwable>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val errors: SharedFlow<Throwable> = _errors.asSharedFlow()

    var isInitialLoad: Boolean = true

    private val alwaysShowSensitiveMedia: Boolean
    private val alwaysOpenSpoiler: Boolean

    init {
        val activeAccount = accountManager.activeAccount
        alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
        alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false

        viewModelScope.launch {
            eventHub.events
                .collect { event ->
                    when (event) {
                        is StatusChangedEvent -> handleStatusChangedEvent(event.status)
                        is BlockEvent -> removeAllByAccountId(event.accountId)
                        is StatusComposedEvent -> handleStatusComposedEvent(event)
                        is StatusDeletedEvent -> handleStatusDeletedEvent(event)
                    }
                }
        }

        loadFilters()
    }

    fun loadThread(id: String) {
        _uiState.value = ThreadUiState.Loading

        viewModelScope.launch {
            Log.d(TAG, "Finding status with: $id")
            val contextCall = async { api.statusContext(id) }
            val timelineStatus = db.timelineDao().getStatus(accountManager.activeAccount!!.id, id)

            var detailedStatus = if (timelineStatus != null) {
                Log.d(TAG, "Loaded status from local timeline")
                val viewData = timelineStatus.toViewData(
                    moshi,
                    isDetailed = true,
                ) as StatusViewData.Concrete

                // Return the correct status, depending on which one matched. If you do not do
                // this the status IDs will be different between the status that's displayed with
                // ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent
                // status content is the same. Then the status flickers as it is drawn twice.
                if (viewData.actionableId == id) {
                    viewData.actionable.toViewData(isDetailed = true)
                } else {
                    viewData
                }
            } else {
                Log.d(TAG, "Loaded status from network")
                val result = api.status(id).getOrElse { exception ->
                    _uiState.value = ThreadUiState.Error(exception)
                    return@launch
                }
                result.toViewData(isDetailed = true)
            }

            _uiState.value = ThreadUiState.LoadingThread(
                statusViewDatum = detailedStatus,
                revealButton = detailedStatus.getRevealButtonState()
            )

            // If the detailedStatus was loaded from the database it might be out-of-date
            // compared to the remote one. Now the user has a working UI do a background fetch
            // for the status. Ignore errors, the user still has a functioning UI if the fetch
            // failed.
            if (timelineStatus != null) {
                api.status(id).getOrNull()?.let { result ->
                    db.timelineDao().update(
                        accountId = accountManager.activeAccount!!.id,
                        status = result
                    )
                    detailedStatus = result.toViewData(isDetailed = true)
                }
            }

            val contextResult = contextCall.await()

            contextResult.fold({ statusContext ->
                val ancestors =
                    statusContext.ancestors.map { status -> status.toViewData() }.filter()
                val descendants =
                    statusContext.descendants.map { status -> status.toViewData() }.filter()
                val statuses = ancestors + detailedStatus + descendants

                _uiState.value = ThreadUiState.Success(
                    statusViewData = statuses,
                    detailedStatusPosition = ancestors.size,
                    revealButton = statuses.getRevealButtonState()
                )
            }, { throwable ->
                _errors.emit(throwable)
                _uiState.value = ThreadUiState.Success(
                    statusViewData = listOf(detailedStatus),
                    detailedStatusPosition = 0,
                    revealButton = RevealButtonState.NO_BUTTON
                )
            })
        }
    }

    fun retry(id: String) {
        _uiState.value = ThreadUiState.Loading
        loadThread(id)
    }

    fun refresh(id: String) {
        _uiState.value = ThreadUiState.Refreshing
        loadThread(id)
    }

    fun detailedStatus(): StatusViewData.Concrete? {
        return when (val uiState = _uiState.value) {
            is ThreadUiState.Success -> uiState.statusViewData.find { status ->
                status.isDetailed
            }

            is ThreadUiState.LoadingThread -> uiState.statusViewDatum
            else -> null
        }
    }

    fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
        try {
            timelineCases.reblog(status.actionableId, reblog).getOrThrow()
        } catch (t: Exception) {
            ifExpected(t) {
                Log.d(TAG, "Failed to reblog status " + status.actionableId, t)
            }
        }
    }

    fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
        try {
            timelineCases.favourite(status.actionableId, favorite).getOrThrow()
        } catch (t: Exception) {
            ifExpected(t) {
                Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
            }
        }
    }

    fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
        try {
            timelineCases.bookmark(status.actionableId, bookmark).getOrThrow()
        } catch (t: Exception) {
            ifExpected(t) {
                Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
            }
        }
    }

    fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete): Job =
        viewModelScope.launch {
            val poll = status.status.actionableStatus.poll ?: run {
                Log.w(TAG, "No poll on status ${status.id}")
                return@launch
            }

            val votedPoll = poll.votedCopy(choices)
            updateStatus(status.id) { status ->
                status.copy(poll = votedPoll)
            }

            try {
                timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow()
            } catch (t: Exception) {
                ifExpected(t) {
                    Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
                }
            }
        }

    fun removeStatus(statusToRemove: StatusViewData.Concrete) {
        updateSuccess { uiState ->
            uiState.copy(
                statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove }
            )
        }
    }

    fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
        updateSuccess { uiState ->
            val statuses = uiState.statusViewData.map { viewData ->
                if (viewData.id == status.id) {
                    viewData.copy(isExpanded = expanded)
                } else {
                    viewData
                }
            }
            uiState.copy(
                statusViewData = statuses,
                revealButton = statuses.getRevealButtonState()
            )
        }
    }

    fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
        updateStatusViewData(status.id) { viewData ->
            viewData.copy(isShowingContent = isShowing)
        }
    }

    fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
        updateStatusViewData(status.id) { viewData ->
            viewData.copy(isCollapsed = isCollapsed)
        }
    }

    suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> {
        updateStatusViewData(status.id) { viewData ->
            viewData.copy(translation = TranslationViewData.Loading)
        }
        return timelineCases.translate(status.actionableId)
            .map { translation ->
                updateStatusViewData(status.id) { viewData ->
                    viewData.copy(translation = TranslationViewData.Loaded(translation))
                }
            }
            .onFailure {
                updateStatusViewData(status.id) { viewData ->
                    viewData.copy(translation = null)
                }
            }
    }

    fun untranslate(status: StatusViewData.Concrete) {
        updateStatusViewData(status.id) { viewData ->
            viewData.copy(translation = null)
        }
    }

    private fun handleStatusChangedEvent(status: Status) {
        updateStatusViewData(status.id) { viewData ->
            status.toViewData(
                isShowingContent = viewData.isShowingContent,
                isExpanded = viewData.isExpanded,
                isCollapsed = viewData.isCollapsed,
                isDetailed = viewData.isDetailed,
                translation = viewData.translation,
            )
        }
    }

    private fun removeAllByAccountId(accountId: String) {
        updateSuccess { uiState ->
            uiState.copy(
                statusViewData = uiState.statusViewData.filter { viewData ->
                    viewData.status.account.id != accountId
                }
            )
        }
    }

    private fun handleStatusComposedEvent(event: StatusComposedEvent) {
        val eventStatus = event.status
        updateSuccess { uiState ->
            val statuses = uiState.statusViewData
            val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
            val repliedIndex =
                statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
            if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
                // there is a new reply to the detailed status or below -> display it
                val newStatuses = statuses.subList(0, repliedIndex + 1) +
                    eventStatus.toViewData() +
                    statuses.subList(repliedIndex + 1, statuses.size)
                uiState.copy(statusViewData = newStatuses)
            } else {
                uiState
            }
        }
    }

    private fun handleStatusDeletedEvent(event: StatusDeletedEvent) {
        updateSuccess { uiState ->
            uiState.copy(
                statusViewData = uiState.statusViewData.filter { status ->
                    status.id != event.statusId
                }
            )
        }
    }

    fun toggleRevealButton() {
        updateSuccess { uiState ->
            when (uiState.revealButton) {
                RevealButtonState.HIDE -> uiState.copy(
                    statusViewData = uiState.statusViewData.map { viewData ->
                        viewData.copy(isExpanded = false)
                    },
                    revealButton = RevealButtonState.REVEAL
                )

                RevealButtonState.REVEAL -> uiState.copy(
                    statusViewData = uiState.statusViewData.map { viewData ->
                        viewData.copy(isExpanded = true)
                    },
                    revealButton = RevealButtonState.HIDE
                )

                else -> uiState
            }
        }
    }

    private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState {
        val hasWarnings = status.spoilerText.isNotEmpty()

        return if (hasWarnings) {
            if (isExpanded) {
                RevealButtonState.HIDE
            } else {
                RevealButtonState.REVEAL
            }
        } else {
            RevealButtonState.NO_BUTTON
        }
    }

    /**
     * Get the reveal button state based on the state of all the statuses in the list.
     *
     * - If any status sets it to REVEAL, use REVEAL
     * - If no status sets it to REVEAL, but at least one uses HIDE, use HIDE
     * - Otherwise use NO_BUTTON
     */
    private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState {
        var seenHide = false

        forEach {
            when (val state = it.getRevealButtonState()) {
                RevealButtonState.NO_BUTTON -> return@forEach
                RevealButtonState.REVEAL -> return state
                RevealButtonState.HIDE -> seenHide = true
            }
        }

        if (seenHide) {
            return RevealButtonState.HIDE
        }

        return RevealButtonState.NO_BUTTON
    }

    private fun loadFilters() {
        viewModelScope.launch {
            api.getFilters().fold(
                {
                    filterModel.kind = Filter.Kind.THREAD
                    updateStatuses()
                },
                { throwable ->
                    if (throwable.isHttpNotFound()) {
                        val filters = api.getFiltersV1().getOrElse {
                            Log.w(TAG, "Failed to fetch filters", it)
                            return@launch
                        }

                        filterModel.initWithFilters(
                            filters.filter { filter -> filter.context.contains(FilterV1.THREAD) }
                        )
                        updateStatuses()
                    } else {
                        Log.e(TAG, "Error getting filters", throwable)
                    }
                }
            )
        }
    }

    private fun updateStatuses() {
        updateSuccess { uiState ->
            val statuses = uiState.statusViewData.filter()
            uiState.copy(
                statusViewData = statuses,
                revealButton = statuses.getRevealButtonState()
            )
        }
    }

    private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> {
        return filter { status ->
            if (status.isDetailed) {
                true
            } else {
                status.filterAction = filterModel.shouldFilterStatus(status.status)
                status.filterAction != Filter.Action.HIDE
            }
        }
    }

    private fun Status.toViewData(isDetailed: Boolean = false): StatusViewData.Concrete {
        val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find {
            it.id == this.id
        }
        return toViewData(
            isShowingContent = oldStatus?.isShowingContent
                ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
            isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
            isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
            isDetailed = oldStatus?.isDetailed ?: isDetailed
        )
    }

    private inline fun updateSuccess(updater: (ThreadUiState.Success) -> ThreadUiState.Success) {
        _uiState.update { uiState ->
            if (uiState is ThreadUiState.Success) {
                updater(uiState)
            } else {
                uiState
            }
        }
    }

    private fun updateStatusViewData(
        statusId: String,
        updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
    ) {
        updateSuccess { uiState ->
            uiState.copy(
                statusViewData = uiState.statusViewData.map { viewData ->
                    if (viewData.id == statusId) {
                        updater(viewData)
                    } else {
                        viewData
                    }
                }
            )
        }
    }

    private fun updateStatus(statusId: String, updater: (Status) -> Status) {
        updateStatusViewData(statusId) { viewData ->
            viewData.copy(
                status = updater(viewData.status)
            )
        }
    }

    fun clearWarning(viewData: StatusViewData.Concrete) {
        updateStatus(viewData.id) { status ->
            status.copy(filtered = emptyList())
        }
    }

    companion object {
        private const val TAG = "ViewThreadViewModel"
    }
}

sealed interface ThreadUiState {
    /** The initial load of the detailed status for this thread */
    data object Loading : ThreadUiState

    /** Loading the detailed status has completed, now loading ancestors/descendants */
    data class LoadingThread(
        val statusViewDatum: StatusViewData.Concrete?,
        val revealButton: RevealButtonState
    ) : ThreadUiState

    /** An error occurred at any point */
    class Error(val throwable: Throwable) : ThreadUiState

    /** Successfully loaded the full thread */
    data class Success(
        val statusViewData: List<StatusViewData.Concrete>,
        val revealButton: RevealButtonState,
        val detailedStatusPosition: Int
    ) : ThreadUiState

    /** Refreshing the thread with a swipe */
    data object Refreshing : ThreadUiState
}

enum class RevealButtonState {
    NO_BUTTON,
    REVEAL,
    HIDE
}
