/* Copyright 2019 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.compose

import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.MediaToSend
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject

class ComposeViewModel @Inject constructor(
    private val api: MastodonApi,
    private val accountManager: AccountManager,
    private val mediaUploader: MediaUploader,
    private val serviceClient: ServiceClient,
    private val draftHelper: DraftHelper,
    instanceInfoRepo: InstanceInfoRepository
) : ViewModel() {

    private var replyingStatusAuthor: String? = null
    private var replyingStatusContent: String? = null
    internal var startingText: String? = null
    internal var postLanguage: String? = null
    private var draftId: Int = 0
    private var scheduledTootId: String? = null
    private var startingContentWarning: String = ""
    private var inReplyToId: String? = null
    private var originalStatusId: String? = null
    private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN

    private var contentWarningStateChanged: Boolean = false
    private var modifiedInitialState: Boolean = false
    private var hasScheduledTimeChanged: Boolean = false

    val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
        .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)

    val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
        .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)

    val markMediaAsSensitive: MutableStateFlow<Boolean> =
        MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)

    val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
    val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
    val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
    val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)

    val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
    val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)

    private lateinit var composeKind: ComposeKind

    // Used in ComposeActivity to pass state to result function when cropImage contract inflight
    var cropImageItemOld: QueuedMedia? = null

    private var setupComplete = false

    suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
        try {
            val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
            val mediaItems = media.value
            if (type != QueuedMedia.Type.IMAGE &&
                mediaItems.isNotEmpty() &&
                mediaItems[0].type == QueuedMedia.Type.IMAGE
            ) {
                Result.failure(VideoOrImageException())
            } else {
                val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
                Result.success(queuedMedia)
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun addMediaToQueue(
        type: QueuedMedia.Type,
        uri: Uri,
        mediaSize: Long,
        description: String? = null,
        focus: Attachment.Focus? = null,
        replaceItem: QueuedMedia? = null
    ): QueuedMedia {
        var stashMediaItem: QueuedMedia? = null

        media.update { mediaList ->
            val mediaItem = QueuedMedia(
                localId = mediaUploader.getNewLocalMediaId(),
                uri = uri,
                type = type,
                mediaSize = mediaSize,
                description = description,
                focus = focus,
                state = QueuedMedia.State.UPLOADING
            )
            stashMediaItem = mediaItem

            if (replaceItem != null) {
                mediaUploader.cancelUploadScope(replaceItem.localId)
                mediaList.map {
                    if (it.localId == replaceItem.localId) mediaItem else it
                }
            } else { // Append
                mediaList + mediaItem
            }
        }
        val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that

        viewModelScope.launch {
            mediaUploader
                .uploadMedia(mediaItem, instanceInfo.first())
                .collect { event ->
                    val item = media.value.find { it.localId == mediaItem.localId }
                        ?: return@collect
                    val newMediaItem = when (event) {
                        is UploadEvent.ProgressEvent ->
                            item.copy(uploadPercent = event.percentage)
                        is UploadEvent.FinishedEvent ->
                            item.copy(
                                id = event.mediaId,
                                uploadPercent = -1,
                                state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED }
                            )
                        is UploadEvent.ErrorEvent -> {
                            media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
                            uploadError.emit(event.error)
                            return@collect
                        }
                    }
                    media.update { mediaList ->
                        mediaList.map { mediaItem ->
                            if (mediaItem.localId == newMediaItem.localId) {
                                newMediaItem
                            } else {
                                mediaItem
                            }
                        }
                    }
                }
        }
        return mediaItem
    }

    private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
        media.update { mediaList ->
            val mediaItem = QueuedMedia(
                localId = mediaUploader.getNewLocalMediaId(),
                uri = uri,
                type = type,
                mediaSize = 0,
                uploadPercent = -1,
                id = id,
                description = description,
                focus = focus,
                state = QueuedMedia.State.PUBLISHED
            )
            mediaList + mediaItem
        }
    }

    fun removeMediaFromQueue(item: QueuedMedia) {
        mediaUploader.cancelUploadScope(item.localId)
        media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
    }

    fun toggleMarkSensitive() {
        this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
    }

    fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
        return if (didChange(contentText, contentWarning)) {
            when (composeKind) {
                ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
                    ConfirmationKind.NONE
                } else {
                    ConfirmationKind.SAVE_OR_DISCARD
                }
                ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
                    ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
                } else {
                    ConfirmationKind.UPDATE_OR_DISCARD
                }
                ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
                ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
            }
        } else {
            ConfirmationKind.NONE
        }
    }

    private fun didChange(content: String?, contentWarning: String?): Boolean {
        val textChanged = content.orEmpty() != startingText.orEmpty()
        val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
        val mediaChanged = media.value.isNotEmpty()
        val pollChanged = poll.value != null
        val didScheduledTimeChange = hasScheduledTimeChanged

        return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
    }

    private fun isEmpty(content: String?, contentWarning: String?): Boolean {
        return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
    }

    fun contentWarningChanged(value: Boolean) {
        showContentWarning.value = value
        contentWarningStateChanged = true
    }

    fun deleteDraft() {
        viewModelScope.launch {
            if (draftId != 0) {
                draftHelper.deleteDraftAndAttachments(draftId)
            }
        }
    }

    fun stopUploads() {
        mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray())
    }

    fun shouldShowSaveDraftDialog(): Boolean {
        // if any of the media files need to be downloaded first it could take a while, so show a loading dialog
        return media.value.any { mediaValue ->
            mediaValue.uri.scheme == "https"
        }
    }

    suspend fun saveDraft(content: String, contentWarning: String) {
        val mediaUris: MutableList<String> = mutableListOf()
        val mediaDescriptions: MutableList<String?> = mutableListOf()
        val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
        for (item in media.value) {
            mediaUris.add(item.uri.toString())
            mediaDescriptions.add(item.description)
            mediaFocus.add(item.focus)
        }

        draftHelper.saveDraft(
            draftId = draftId,
            accountId = accountManager.activeAccount?.id!!,
            inReplyToId = inReplyToId,
            content = content,
            contentWarning = contentWarning,
            sensitive = markMediaAsSensitive.value,
            visibility = statusVisibility.value,
            mediaUris = mediaUris,
            mediaDescriptions = mediaDescriptions,
            mediaFocus = mediaFocus,
            poll = poll.value,
            failedToSend = false,
            failedToSendAlert = false,
            scheduledAt = scheduledAt.value,
            language = postLanguage,
            statusId = originalStatusId
        )
    }

    /**
     * Send status to the server.
     * Uses current state plus provided arguments.
     */
    suspend fun sendStatus(
        content: String,
        spoilerText: String,
        accountId: Long
    ) {
        if (!scheduledTootId.isNullOrEmpty()) {
            api.deleteScheduledStatus(scheduledTootId!!)
        }

        val attachedMedia = media.value.map { item ->
            MediaToSend(
                localId = item.localId,
                id = item.id,
                uri = item.uri.toString(),
                description = item.description,
                focus = item.focus,
                processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED
            )
        }
        val tootToSend = StatusToSend(
            text = content,
            warningText = spoilerText,
            visibility = statusVisibility.value.serverString(),
            sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
            media = attachedMedia,
            scheduledAt = scheduledAt.value,
            inReplyToId = inReplyToId,
            poll = poll.value,
            replyingStatusContent = null,
            replyingStatusAuthorUsername = null,
            accountId = accountId,
            draftId = draftId,
            idempotencyKey = randomAlphanumericString(16),
            retries = 0,
            language = postLanguage,
            statusId = originalStatusId
        )

        serviceClient.sendToot(tootToSend)
    }

    private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
        media.update { mediaList ->
            mediaList.map { mediaItem ->
                if (mediaItem.localId == localId) {
                    mutator(mediaItem)
                } else {
                    mediaItem
                }
            }
        }
    }

    fun updateDescription(localId: Int, description: String) {
        updateMediaItem(localId) { mediaItem ->
            mediaItem.copy(description = description)
        }
    }

    fun updateFocus(localId: Int, focus: Attachment.Focus) {
        updateMediaItem(localId) { mediaItem ->
            mediaItem.copy(focus = focus)
        }
    }

    fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
        when (token[0]) {
            '@' -> {
                return api.searchAccountsSync(query = token.substring(1), limit = 10)
                    .fold({ accounts ->
                        accounts.map { AutocompleteResult.AccountResult(it) }
                    }, { e ->
                        Log.e(TAG, "Autocomplete search for $token failed.", e)
                        emptyList()
                    })
            }
            '#' -> {
                return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
                    .fold({ searchResult ->
                        searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
                    }, { e ->
                        Log.e(TAG, "Autocomplete search for $token failed.", e)
                        emptyList()
                    })
            }
            ':' -> {
                val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
                val incomplete = token.substring(1)

                return emojiList.filter { emoji ->
                    emoji.shortcode.contains(incomplete, ignoreCase = true)
                }.sortedBy { emoji ->
                    emoji.shortcode.indexOf(incomplete, ignoreCase = true)
                }.map { emoji ->
                    AutocompleteResult.EmojiResult(emoji)
                }
            }
            else -> {
                Log.w(TAG, "Unexpected autocompletion token: $token")
                return emptyList()
            }
        }
    }

    fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
        if (setupComplete) {
            return
        }

        composeKind = composeOptions?.kind ?: ComposeKind.NEW

        val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy

        val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
        startingVisibility = Status.Visibility.byNum(
            preferredVisibility.num.coerceAtLeast(replyVisibility.num)
        )

        inReplyToId = composeOptions?.inReplyToId

        modifiedInitialState = composeOptions?.modifiedInitialState == true

        val contentWarning = composeOptions?.contentWarning
        if (contentWarning != null) {
            startingContentWarning = contentWarning
        }
        if (!contentWarningStateChanged) {
            showContentWarning.value = !contentWarning.isNullOrBlank()
        }

        // recreate media list
        val draftAttachments = composeOptions?.draftAttachments
        if (draftAttachments != null) {
            // when coming from DraftActivity
            viewModelScope.launch {
                draftAttachments.forEach { attachment ->
                    pickMedia(attachment.uri, attachment.description, attachment.focus)
                }
            }
        } else {
            composeOptions?.mediaAttachments?.forEach { a ->
                // when coming from redraft or ScheduledTootActivity
                val mediaType = when (a.type) {
                    Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
                    Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
                    Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
                }
                addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
            }
        }

        draftId = composeOptions?.draftId ?: 0
        scheduledTootId = composeOptions?.scheduledTootId
        originalStatusId = composeOptions?.statusId
        startingText = composeOptions?.content
        postLanguage = composeOptions?.language

        val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
        if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
            startingVisibility = tootVisibility
        }
        statusVisibility.value = startingVisibility
        val mentionedUsernames = composeOptions?.mentionedUsernames
        if (mentionedUsernames != null) {
            val builder = StringBuilder()
            for (name in mentionedUsernames) {
                builder.append('@')
                builder.append(name)
                builder.append(' ')
            }
            startingText = builder.toString()
        }

        scheduledAt.value = composeOptions?.scheduledAt

        composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }

        val poll = composeOptions?.poll
        if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
            this.poll.value = poll
        }
        replyingStatusContent = composeOptions?.replyingStatusContent
        replyingStatusAuthor = composeOptions?.replyingStatusAuthor

        setupComplete = true
    }

    fun updatePoll(newPoll: NewPoll) {
        poll.value = newPoll
    }

    fun updateScheduledAt(newScheduledAt: String?) {
        if (newScheduledAt != scheduledAt.value) {
            hasScheduledTimeChanged = true
        }

        scheduledAt.value = newScheduledAt
    }

    val editing: Boolean
        get() = !originalStatusId.isNullOrEmpty()

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

    enum class ConfirmationKind {
        NONE, // just close
        SAVE_OR_DISCARD,
        UPDATE_OR_DISCARD,
        CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
        CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
    }
}

/**
 * Thrown when trying to add an image when video is already present or the other way around
 */
class VideoOrImageException : Exception()
