package net.buzzert.kordophone.backend.server

import android.net.Uri
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.db.CachedChatDatabase
import net.buzzert.kordophone.backend.events.MessageDeliveredEvent
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.OutgoingMessage
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.BufferedSource
import java.io.InputStream
import java.util.Date
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue

const val REPO_LOG: String = "ChatRepository"
const val CONVERSATION_MESSAGE_SYNC_COUNT = 10

class ChatRepository(
    private var apiClient: APIClient,
    private val database: CachedChatDatabase,
) {
    sealed class Error {
        open val title: String = "Error"
        open val description: String = "Generic Error"

        data class ConnectionError(val message: String?): Error() {
            override val title: String = "Connection Error"
            override val description: String = message ?: "???"
        }

        data class AttachmentError(val message: String): Error() {
            override val title: String = "Attachment Error"
            override val description: String = message
        }
    }

    // All (Cached) Conversations
    val conversations: List<Conversation>
        get() = database.fetchConversations()

    // Channel that's signaled when an outgoing message is delivered.
    val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
        get() = _messageDeliveredChannel.asSharedFlow()

    // Changes Flow
    val conversationChanges: Flow<List<Conversation>>
        get() = database.conversationChanges
            .onEach { Log.d(REPO_LOG, "Got database conversations changed") }

    // New Messages
    val newMessages: SharedFlow<Message>
        get() = _newMessageChannel.asSharedFlow()

    // Errors channel
    val errorEncounteredChannel: SharedFlow<Error>
        get() = _errorEncounteredChannel.asSharedFlow()

    val isConfigured: Boolean
        get() = apiClient.isConfigured

    // New messages for a particular conversation
    fun messagesChanged(conversation: Conversation): Flow<List<Message>> =
        database.messagesChanged(conversation)

    // Testing harness
    internal class TestingHarness(private val repository: ChatRepository) {
        suspend fun fetchConversations(): List<Conversation> {
            return repository.fetchConversations()
        }

        suspend fun fetchMessages(conversation: Conversation): List<Message> {
            return repository.fetchMessages(conversation)
        }
    }

    internal fun testingHarness(): TestingHarness = TestingHarness(this)

    private var apiInterface = apiClient.getAPIInterface()
    private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessage> = ArrayBlockingQueue(16)
    private var outgoingMessageThread: Thread? = null
    private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
    private val _errorEncounteredChannel = MutableSharedFlow<Error>()
    private val _newMessageChannel = MutableSharedFlow<Message>()

    private var updateMonitor = UpdateMonitor(apiClient)
    private var updateWatchJob: Job? = null
    private var updateWatchScope: CoroutineScope? = null

    fun updateAPIClient(client: APIClient) {
        this.apiClient = client
        this.apiInterface = client.getAPIInterface()
        this.updateMonitor = UpdateMonitor(client)

        // Restart update watch job, if necessary.
        if (this.updateWatchJob != null) {
            stopWatchingForUpdates()
            beginWatchingForUpdates(updateWatchScope!!)
        }
    }

    suspend fun getVersion(): String {
        return apiInterface.getVersion().string()
    }

    fun beginWatchingForUpdates(scope: CoroutineScope) {
        updateWatchJob?.cancel()
        updateWatchJob = CoroutineScope(scope.coroutineContext).launch {
            launch {
                updateMonitor.conversationChanged.collect { handleConversationChangedUpdate(it) }
            }
            launch {
                updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
            }
            launch {
                messageDeliveredChannel.collectLatest { handleMessageDelivered(it) }
            }
        }

        updateWatchScope = scope
        updateMonitor.beginMonitoringUpdates()
    }

    fun stopWatchingForUpdates() {
        updateWatchJob?.cancel()
        updateWatchJob = null

        updateMonitor.stopMonitoringForUpdates()
    }

    fun enqueueOutgoingMessage(message: OutgoingMessage): GUID {
        val guid = UUID.randomUUID().toString()

        Log.d(REPO_LOG, "Enqueuing outgoing message: $message ($guid)")
        outgoingMessageQueue.add(message)

        if (outgoingMessageThread == null) {
            outgoingMessageThread = Thread { outgoingMessageQueueMain() }
            outgoingMessageThread?.start()
        }

        return guid
    }

    fun conversationForGuid(guid: GUID): Conversation {
        return database.getConversationByGuid(guid).toConversation()
    }

    fun messagesForConversation(conversation: Conversation): List<Message> {
        return database.fetchMessages(conversation)
    }

    suspend fun synchronize() = withErrorChannelHandling {
        Log.d(REPO_LOG, "Synchronizing conversations")

        // Sync conversations
        val serverConversations = fetchConversations()
        database.updateConversations(serverConversations)

        // Sync top N number of conversations' message content
        Log.d(REPO_LOG, "Synchronizing messages")
        val sortedConversations = conversations.sortedBy { it.date }.reversed()
        for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
            synchronizeConversation(conversation)
        }
    }

    suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = withErrorChannelHandling {
        val messages = fetchMessages(conversation, limit = limit, afterGUID = conversation.lastFetchedMessageGUID)
        database.writeMessages(messages, conversation)
    }

    suspend fun markConversationAsRead(conversation: Conversation) = withErrorChannelHandling(silent = true) {
        apiInterface.markConversation(conversation.guid)
    }

    suspend fun fetchAttachmentDataSource(guid: String, preview: Boolean): BufferedSource {
        return apiInterface.fetchAttachment(guid, preview).source()
    }

    private suspend fun uploadAttachment(filename: String, mediaType: String, source: InputStream): String {
        val attachmentData = source.readBytes()
        val requestBody = RequestBody.create(MediaType.get(mediaType), attachmentData)

        withContext(Dispatchers.IO) {
            source.close()
        }

        val response = apiInterface.uploadAttachment(filename, requestBody)
        return response.bodyOnSuccessOrThrow().transferGUID
    }

    fun close() {
        database.close()
    }

    // - private

    private suspend fun withErrorChannelHandling(silent: Boolean = false, body: suspend () -> Unit) {
        try {
            body()
        } catch (e: InvalidConfigurationAPIClient.NotConfiguredError) {
            // Not configured yet: ignore.
        } catch (e: java.lang.Exception) {
            if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
        } catch (e: java.lang.Error) {
            if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
        }
    }

    private suspend fun fetchConversations(): List<Conversation> {
        return apiInterface.getConversations().bodyOnSuccessOrThrow()
    }

    private suspend fun fetchMessages(
        conversation: Conversation,
        limit: Int? = null,
        beforeGUID: String? = null,
        afterGUID: String? = null,
    ): List<Message> {
        return apiInterface.getMessages(conversation.guid, limit, beforeGUID, afterGUID)
            .bodyOnSuccessOrThrow()
            .onEach { it.conversation = conversation }
    }

    private fun handleConversationChangedUpdate(conversation: Conversation) {
        Log.d(REPO_LOG, "Handling conversation changed update")
        database.writeConversations(listOf(conversation))
    }

    private suspend fun handleMessageAddedUpdate(message: Message) {
        Log.d(REPO_LOG, "Handling messages added update")
        database.writeMessages(listOf(message), message.conversation)
        _newMessageChannel.emit(message)
    }

    private suspend fun handleMessageDelivered(event: MessageDeliveredEvent) {
        Log.d(REPO_LOG, "Handling successful delivery event")

        // Unfortunate protocol reality: the server doesn't tell us about new messages that are from us,
        // so we have to explicitly handle this like a messageAddedUpdate.
        database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
    }

    private suspend fun retryMessageSend(info: OutgoingMessage) {
        delay(5000L)
        outgoingMessageQueue.add(info)
    }

    private fun outgoingMessageQueueMain() {
        Log.d(REPO_LOG, "Outgoing Message Queue Main")
        while (true) {
            outgoingMessageQueue.take().let {
                runBlocking {
                    val conversation = it.conversation
                    val requestGuid = it.guid
                    val body = it.body

                    Log.d(REPO_LOG, "Sending message to $conversation: $requestGuid")

                    // Upload attachments first
                    val attachmentGUIDs = mutableListOf<String>()
                    try {
                        for (uri: Uri in it.attachmentUris) {
                            val uploadData = it.attachmentDataSource(uri)
                                ?: throw java.lang.Exception("No upload data.")

                            val guid = uploadAttachment(uploadData.filename, uploadData.mimeType, uploadData.inputStream)
                            attachmentGUIDs.add(guid)
                        }
                    } catch (e: java.lang.Exception) {
                        Log.e(REPO_LOG, "Error uploading attachment (${e.message}). Dropping...")
                        _errorEncounteredChannel.emit(Error.AttachmentError("Upload error: ${e.message}"))
                    }

                    try {
                        val result = apiInterface.sendMessage(
                            SendMessageRequest(
                                conversationGUID = conversation.guid,
                                body = body,
                                transferGUIDs = attachmentGUIDs,
                            )
                        )

                        if (result.isSuccessful) {
                            val messageGuid = result.body()?.sentMessageGUID ?: requestGuid
                            Log.d(REPO_LOG, "Successfully sent message: $messageGuid")

                            val newMessage = Message(
                                guid = messageGuid,
                                text = body,
                                sender = null,
                                conversation = it.conversation,
                                date = Date(),
                                attachmentGUIDs = attachmentGUIDs,
                            )

                            _messageDeliveredChannel.emit(
                                MessageDeliveredEvent(
                                    newMessage,
                                    conversation,
                                    requestGuid
                                )
                            )
                        } else {
                            Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
                            retryMessageSend(it)
                        }
                    } catch (e: java.lang.Exception) {
                        Log.e(REPO_LOG, "Error sending message: (${e.message}). Enqueuing for retry in 5 sec.")
                        retryMessageSend(it)
                    }
                }
            }
        }
    }
}