UiModelMapper.kt

package chat.rocket.android.chatroom.uimodel

import DateTimeHelper
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import androidx.core.content.ContextCompat
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.text.scale
import chat.rocket.android.R
import chat.rocket.android.chatinformation.viewmodel.ReadReceiptViewModel
import chat.rocket.android.chatroom.domain.MessageReply
import chat.rocket.android.dagger.scope.PerFragment
import chat.rocket.android.db.DatabaseManager
import chat.rocket.android.emoji.EmojiParser
import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.helper.MessageHelper
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.helper.UserHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.GetSettingsInteractor
import chat.rocket.android.server.domain.TokenRepository
import chat.rocket.android.server.domain.baseUrl
import chat.rocket.android.server.domain.messageReadReceiptEnabled
import chat.rocket.android.server.domain.messageReadReceiptStoreUsers
import chat.rocket.android.server.domain.useRealName
import chat.rocket.android.server.infrastructure.ConnectionManagerFactory
import chat.rocket.android.util.extension.isImage
import chat.rocket.android.util.extensions.avatarUrl
import chat.rocket.android.util.extensions.ifNotNullNorEmpty
import chat.rocket.android.util.extensions.isNotNullNorEmpty
import chat.rocket.common.model.roomTypeOf
import chat.rocket.core.model.ChatRoom
import chat.rocket.core.model.Message
import chat.rocket.core.model.MessageType
import chat.rocket.core.model.ReadReceipt
import chat.rocket.core.model.attachment.Attachment
import chat.rocket.core.model.attachment.Field
import chat.rocket.core.model.isSystemMessage
import chat.rocket.core.model.url.Url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.security.InvalidParameterException
import java.util.*
import java.util.Collections.emptyList
import javax.inject.Inject

@PerFragment
class UiModelMapper @Inject constructor(
    private val context: Context,
    private val parser: MessageParser,
    private val dbManager: DatabaseManager?,
    private val messageHelper: MessageHelper,
    private val userHelper: UserHelper,
    tokenRepository: TokenRepository,
    serverInteractor: GetCurrentServerInteractor,
    getSettingsInteractor: GetSettingsInteractor,
    localRepository: LocalRepository,
    factory: ConnectionManagerFactory
) {
    private val currentServer = serverInteractor.get()!!
    private val manager = factory.create(currentServer)
    private val client = manager?.client
    private val settings = getSettingsInteractor.get(currentServer)
    private val baseUrl = currentServer
    private val token = tokenRepository.get(currentServer)
    private val currentUsername: String? = localRepository.get(LocalRepository.CURRENT_USERNAME_KEY)
    private val secondaryTextColor = ContextCompat.getColor(context, R.color.colorSecondaryText)

    suspend fun map(
        message: Message,
        roomUiModel: RoomUiModel = RoomUiModel(roles = emptyList(), isBroadcast = true),
        showDateAndHour: Boolean = false
    ): List<BaseUiModel<*>> =
        withContext(Dispatchers.IO) {
            return@withContext translate(message, roomUiModel, showDateAndHour)
        }

    suspend fun map(
        messages: List<Message>,
        roomUiModel: RoomUiModel = RoomUiModel(roles = emptyList(), isBroadcast = true),
        asNotReversed: Boolean = false,
        showDateAndHour: Boolean = false
    ): List<BaseUiModel<*>> =
        withContext(Dispatchers.IO) {
            val list = ArrayList<BaseUiModel<*>>(messages.size)

            messages.forEach {
                list.addAll(
                    if (asNotReversed) translateAsNotReversed(it, roomUiModel, showDateAndHour)
                    else translate(it, roomUiModel, showDateAndHour)
                )
            }
            return@withContext list
        }

    suspend fun map(
        readReceipts: List<ReadReceipt>
    ): List<ReadReceiptViewModel> = withContext(Dispatchers.IO) {
        val list = arrayListOf<ReadReceiptViewModel>()

        readReceipts.forEach {
            list.add(
                ReadReceiptViewModel(
                    avatar = baseUrl.avatarUrl(it.user.username!!, token?.userId, token?.authToken),
                    name = userHelper.displayName(it.user),
                    time = DateTimeHelper.getTime(DateTimeHelper.getLocalDateTime(it.timestamp))
                )
            )
        }
        return@withContext list
    }

    private suspend fun translate(
        message: Message,
        roomUiModel: RoomUiModel,
        showDateAndHour: Boolean = false
    ): List<BaseUiModel<*>> =
        withContext(Dispatchers.IO) {
            val list = ArrayList<BaseUiModel<*>>()

            getChatRoomAsync(message.roomId)?.let { chatRoom ->
                message.urls?.forEach { url ->
                    if (url.url.isImage()) {
                        val attachment = Attachment(imageUrl = url.url)
                        mapAttachment(message, attachment, chatRoom)?.let { list.add(it) }
                    } else {
                        mapUrl(message, url, chatRoom)?.let { list.add(it) }
                    }
                }

                message.attachments?.mapNotNull { attachment ->
                    mapAttachment(message, attachment, chatRoom)
                }?.asReversed()?.let {
                    list.addAll(it)
                }

                mapMessage(message, chatRoom, showDateAndHour).let {
                    if (list.isNotEmpty()) {
                        it.preview = list.first().preview
                    }
                    list.add(it)
                }
            }


            for (i in list.size - 1 downTo 0) {
                val next = if (i - 1 < 0) null else list[i - 1]
                list[i].nextDownStreamMessage = next
                mapVisibleActions(list[i])
            }

            if (isBroadcastReplyAvailable(roomUiModel, message)) {
                getChatRoomAsync(message.roomId)?.let { chatRoom ->
                    val replyUiModel = mapMessageReply(message, chatRoom)
                    list.first().nextDownStreamMessage = replyUiModel
                    list.add(0, replyUiModel)
                }
            }

            return@withContext list
        }

    // TODO: move this to new interactor or FetchChatRoomsInteractor?
    private suspend fun getChatRoomAsync(roomId: String): ChatRoom? = withContext(Dispatchers.IO) {
        return@withContext dbManager?.getRoom(id = roomId)?.let {
            client?.let { client ->
                with(it.chatRoom) {
                    ChatRoom(
                        id = id,
                        subscriptionId = subscriptionId,
                        parentId = parentId,
                        type = roomTypeOf(type),
                        unread = unread,
                        broadcast = broadcast ?: false,
                        alert = alert,
                        fullName = fullname,
                        name = name,
                        favorite = favorite ?: false,
                        default = isDefault ?: false,
                        readonly = readonly,
                        open = open,
                        lastMessage = null,
                        archived = false,
                        status = null,
                        user = null,
                        userMentions = userMentions,
                        client = client,
                        announcement = null,
                        description = null,
                        groupMentions = groupMentions,
                        roles = null,
                        topic = null,
                        lastSeen = this.lastSeen,
                        timestamp = timestamp,
                        updatedAt = updatedAt
                    )
                }
            }
        }
    }

    private fun mapVisibleActions(viewModel: BaseUiModel<*>) {
        if (!settings.messageReadReceiptStoreUsers()) {
            viewModel.menuItemsToHide.add(R.id.action_info)
        }
    }

    private suspend fun translateAsNotReversed(
        message: Message,
        roomUiModel: RoomUiModel,
        showDateAndHour: Boolean = false
    ): List<BaseUiModel<*>> =
        withContext(Dispatchers.IO) {
            val list = ArrayList<BaseUiModel<*>>()

            getChatRoomAsync(message.roomId)?.let { chatRoom ->
                mapMessage(message, chatRoom, showDateAndHour).let {
                    if (list.isNotEmpty()) {
                        it.preview = list.first().preview
                    }
                    list.add(it)
                }

                message.attachments?.forEach {
                    val attachment = mapAttachment(message, it, chatRoom)
                    attachment?.let {
                        list.add(attachment)
                    }
                }

                message.urls?.forEach {
                    val url = mapUrl(message, it, chatRoom)
                    url?.let {
                        list.add(url)
                    }
                }
            }

            for (i in list.size - 1 downTo 0) {
                val next = if (i - 1 < 0) null else list[i - 1]
                list[i].nextDownStreamMessage = next
            }

            if (isBroadcastReplyAvailable(roomUiModel, message)) {
                getChatRoomAsync(message.roomId)?.let { chatRoom ->
                    val replyUiModel = mapMessageReply(message, chatRoom)
                    list.first().nextDownStreamMessage = replyUiModel
                    list.add(0, replyUiModel)
                }
            }

            list.dropLast(1).forEach {
                it.reactions = emptyList()
            }
            list.last().reactions = getReactions(message)
            list.last().nextDownStreamMessage = null

            return@withContext list
        }

    private fun isBroadcastReplyAvailable(roomUiModel: RoomUiModel, message: Message): Boolean {
        val senderUsername = message.sender?.username
        return roomUiModel.isRoom && roomUiModel.isBroadcast &&
                !message.isSystemMessage() &&
                senderUsername != currentUsername
    }

    private fun mapMessageReply(message: Message, chatRoom: ChatRoom): MessageReplyUiModel {
        val name = message.sender?.name
        val roomName =
            if (settings.useRealName() && name != null) name else message.sender?.username ?: ""
        val permalink = messageHelper.createPermalink(message, chatRoom)

        val localDateTime = DateTimeHelper.getLocalDateTime(message.timestamp)
        val dayMarkerText = DateTimeHelper.getFormattedDateForMessages(localDateTime, context)

        return MessageReplyUiModel(
            messageId = message.id,
            isTemporary = false,
            reactions = emptyList(),
            message = message,
            preview = mapMessagePreview(message),
            rawData = MessageReply(roomName = roomName, permalink = permalink),
            nextDownStreamMessage = null,
            unread = message.unread,
            currentDayMarkerText = dayMarkerText,
            showDayMarker = false,
            permalink = messageHelper.createPermalink(message, chatRoom, false)
        )
    }

    private fun mapUrl(message: Message, url: Url, chatRoom: ChatRoom): BaseUiModel<*>? {
        if (url.ignoreParse || url.meta == null) return null

        val hostname = url.parsedUrl?.hostname ?: ""
        val thumb = url.meta?.imageUrl
        val title = url.meta?.title
        val description = url.meta?.description

        val localDateTime = DateTimeHelper.getLocalDateTime(message.timestamp)
        val dayMarkerText = DateTimeHelper.getFormattedDateForMessages(localDateTime, context)
        val permalink = messageHelper.createPermalink(message, chatRoom, false)

        return UrlPreviewUiModel(
            message,
            url,
            message.id,
            title,
            hostname,
            description,
            thumb,
            getReactions(message),
            preview = message.copy(message = url.url),
            unread = message.unread,
            showDayMarker = false,
            currentDayMarkerText = dayMarkerText,
            permalink = permalink
        )
    }

    private fun mapAttachment(
        message: Message,
        attachment: Attachment,
        chatRoom: ChatRoom,
        showDateAndHour: Boolean = false
    ): BaseUiModel<*>? {
        return with(attachment) {
            val content = stripMessageQuotes(message)
            val id = attachmentId(message, attachment)

            val localDateTime = DateTimeHelper.getLocalDateTime(message.timestamp)
            val dayMarkerText = DateTimeHelper.getFormattedDateForMessages(localDateTime, context)
            val fieldsText = mapFields(fields)
            val permalink = messageHelper.createPermalink(message, chatRoom, false)

            val attachmentAuthor = attachment.authorName
            val time = attachment.timestamp?.let { getTime(it, showDateAndHour) }

            val imageUrl = attachmentUrl(attachment.imageUrl)
            val videoUrl = attachmentUrl(attachment.videoUrl)
            val audioUrl = attachmentUrl(attachment.audioUrl)
            val titleLink = attachmentUrl(attachment.titleLink)

            val attachmentTitle =
                attachmentTitle(attachment.title, imageUrl, videoUrl, audioUrl, titleLink)

            val attachmentText =
                attachmentText(attachment.text, attachment.attachments?.firstOrNull(), context)
            val attachmentDescription = attachmentDescription(attachment)

            AttachmentUiModel(
                message = message,
                rawData = this,
                messageId = message.id,
                reactions = getReactions(message),
                preview = message.copy(message = content.message),
                isTemporary = !message.synced,
                unread = message.unread,
                currentDayMarkerText = dayMarkerText,
                showDayMarker = false,
                permalink = permalink,
                id = id,
                title = attachmentTitle,
                description = attachmentDescription,
                authorName = attachmentAuthor,
                text = attachmentText,
                color = color?.color,
                imageUrl = imageUrl,
                videoUrl = videoUrl,
                audioUrl = audioUrl,
                titleLink = titleLink,
                type = type,
                messageLink = messageLink,
                timestamp = time,
                authorIcon = authorIcon,
                authorLink = authorLink,
                fields = fieldsText,
                buttonAlignment = buttonAlignment,
                actions = actions
            )
        }
    }

    private fun mapFields(fields: List<Field>?): CharSequence? {
        return fields?.let {
            buildSpannedString {
                it.forEachIndexed { index, field ->
                    bold { append(field.title) }
                    append("\n")
                    if (field.value.isNotEmpty()) {
                        append(field.value)
                    }

                    if (index != it.size - 1) { // it is not the last one, append a new line
                        append("\n\n")
                    }
                }
            }
        }
    }

    private fun attachmentId(message: Message, attachment: Attachment): Long {
        return "${message.id}_${attachment.hashCode()}".hashCode().toLong()
    }

    private fun attachmentTitle(title: String?, vararg url: String?): CharSequence {
        title?.let { return it }

        url.filterNotNull().forEach {
            val fileUrl = HttpUrl.parse(it)
            fileUrl?.let { httpUrl ->
                return httpUrl.pathSegments().last()
            }
        }

        return ""
    }

    private fun attachmentUrl(url: String?): String? {
        if (url.isNullOrEmpty()) return null
        if (url.startsWith("http")) return url

        val fullUrl = "$baseUrl$url"
        val httpUrl = HttpUrl.parse(fullUrl)
        httpUrl?.let {
            return it.newBuilder().apply {
                addQueryParameter("rc_uid", token?.userId)
                addQueryParameter("rc_token", token?.authToken)
            }.build().toString()
        }

        // Fallback to baseUrl + url
        return fullUrl
    }

    private fun attachmentText(text: String?, attachment: Attachment?, context: Context): String? =
        attachment?.run {
            with(context) {
                when {
                    imageUrl.isNotNullNorEmpty() -> getString(R.string.msg_preview_photo)
                    videoUrl.isNotNullNorEmpty() -> getString(R.string.msg_preview_video)
                    audioUrl.isNotNullNorEmpty() -> getString(R.string.msg_preview_audio)
                    titleLink.isNotNullNorEmpty() &&
                            type?.contentEquals("file") == true ->
                        getString(R.string.msg_preview_file)
                    else -> text
                }
            }
        } ?: text

    private fun attachmentDescription(attachment: Attachment): String? {
        return attachment.description
    }

    private suspend fun mapMessage(
        message: Message,
        chatRoom: ChatRoom,
        showDateAndHour: Boolean = false
    ): MessageUiModel = withContext(Dispatchers.IO) {
        val sender = getSenderName(message)
        val time = getTime(message.timestamp, showDateAndHour)
        val avatar = getUserAvatar(message)
        val preview = mapMessagePreview(message)
        val synced = message.synced
        val unread = if (settings.messageReadReceiptEnabled()) {
            message.unread ?: false
        } else {
            null
        }

        val localDateTime = DateTimeHelper.getLocalDateTime(message.timestamp)
        val dayMarkerText = DateTimeHelper.getFormattedDateForMessages(localDateTime, context)
        val permalink = messageHelper.createPermalink(message, chatRoom, false)

        val content = getContent(stripMessageQuotes(message))
        MessageUiModel(
            message = stripMessageQuotes(message), rawData = message,
            messageId = message.id, avatar = avatar!!, time = time, senderName = sender,
            content = content, isPinned = message.pinned, currentDayMarkerText = dayMarkerText,
            showDayMarker = false, reactions = getReactions(message), isFirstUnread = false,
            preview = preview, isTemporary = !synced, unread = unread, permalink = permalink,
            subscriptionId = chatRoom.subscriptionId
        )
    }

    private fun mapMessagePreview(message: Message): Message = when (message.isSystemMessage()) {
        false -> stripMessageQuotes(message)
        true -> message.copy(message = getSystemMessage(message).toString())
    }

    private fun getReactions(message: Message): List<ReactionUiModel> {
        val reactions = message.reactions?.let {
            val list = mutableListOf<ReactionUiModel>()
            val customEmojis = EmojiRepository.getCustomEmojis()
            it.getShortNames().forEach { shortname ->
                it.getUsernames(shortname)?.let { usernames ->
                    val count = usernames.size
                    val custom = customEmojis.firstOrNull { emoji -> emoji.shortname == shortname }
                    list.add(
                        ReactionUiModel(
                            messageId = message.id,
                            shortname = shortname,
                            unicode = EmojiParser.parse(context, shortname),
                            count = count,
                            usernames = usernames,
                            url = custom?.url,
                            isCustom = custom != null
                        )
                    )

                }
            }
            list
        }
        return reactions ?: emptyList()
    }

    private fun stripMessageQuotes(message: Message): Message {
        val baseUrl = settings.baseUrl()
        return message.copy(
            message = message.message.replace(
                "\\[[^\\]]+\\]\\($baseUrl[^)]+\\)".toRegex(),
                ""
            ).trim()
        )
    }

    private fun getSenderName(message: Message): CharSequence {
        val username = message.sender?.username
        message.senderAlias.ifNotNullNorEmpty { alias ->
            return buildSpannedString {
                append(alias)
                username?.let {
                    append(" ")
                    scale(0.8f) {
                        color(secondaryTextColor) {
                            append("@$username")
                        }
                    }
                }
            }
        }

        val realName = message.sender?.name
        val senderName = if (settings.useRealName()) realName else username
        return senderName ?: username.toString()
    }

    private fun getUserAvatar(message: Message): String? {
        message.avatar?.let {
            return it // Always give preference for overridden avatar from message
        }

        val username = message.sender?.username ?: "?"
        return baseUrl.let {
            baseUrl.avatarUrl(username, token?.userId, token?.authToken)
        }
    }

    private fun getTime(timestamp: Long, showDateAndHour: Boolean = false) =
        DateTimeHelper.getTime(DateTimeHelper.getLocalDateTime(timestamp), showDateAndHour)

    private fun getContent(message: Message): CharSequence = when (message.isSystemMessage()) {
        true -> getSystemMessage(message)
        false -> parser.render(message, currentUsername)
    }

    private fun getSystemMessage(message: Message): CharSequence {
        val content = with(context) {
            when (message.type) {
                //TODO: Add implementation for Welcome type.
                is MessageType.MessageRemoved -> getString(R.string.message_removed)
                is MessageType.UserJoined -> getString(R.string.message_user_joined_channel)
                is MessageType.UserLeft -> getString(R.string.message_user_left)
                is MessageType.UserAdded -> getString(
                    R.string.message_user_added_by,
                    message.message,
                    message.sender?.username
                )
                is MessageType.RoomNameChanged -> getString(
                    R.string.message_room_name_changed,
                    message.message,
                    message.sender?.username
                )
                is MessageType.UserRemoved -> getString(
                    R.string.message_user_removed_by,
                    message.message,
                    message.sender?.username
                )
                is MessageType.MessagePinned -> getString(R.string.message_pinned)
                is MessageType.UserMuted -> getString(
                    R.string.message_muted,
                    message.message,
                    message.sender?.username
                )
                is MessageType.UserUnMuted -> getString(
                    R.string.message_unmuted,
                    message.message,
                    message.sender?.username
                )
                is MessageType.SubscriptionRoleAdded -> getString(
                    R.string.message_role_add,
                    message.message,
                    message.role,
                    message.sender?.username
                )
                is MessageType.SubscriptionRoleRemoved -> getString(
                    R.string.message_role_removed,
                    message.message,
                    message.role,
                    message.sender?.username
                )
                is MessageType.RoomChangedPrivacy -> getString(
                    R.string.message_room_changed_privacy,
                    message.message,
                    message.sender?.username
                )
                is MessageType.JitsiCallStarted -> context.getString(
                    R.string.message_video_call_started, message.sender?.username
                )
                else -> throw InvalidParameterException("Invalid message type: ${message.type}")
            }
        }
        val spannableMsg = SpannableStringBuilder(content)
        spannableMsg.setSpan(StyleSpan(Typeface.ITALIC), 0, spannableMsg.length, 0)
        spannableMsg.setSpan(ForegroundColorSpan(Color.GRAY), 0, spannableMsg.length, 0)
        return spannableMsg
    }
}