package com.seven.ikar.kotlin.feature.chat

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
import com.seven.ikar.kotlin.core.model.AttachmentDto
import com.seven.ikar.kotlin.core.model.AuthSessionDto
import com.seven.ikar.kotlin.core.model.BotCommandDto
import com.seven.ikar.kotlin.core.model.CallMediaType
import com.seven.ikar.kotlin.core.model.ChatDetailsDto
import com.seven.ikar.kotlin.core.model.ChatType
import com.seven.ikar.kotlin.core.model.MessageDeliveryState
import com.seven.ikar.kotlin.core.model.MessageDto
import com.seven.ikar.kotlin.core.model.MessageReplyDto
import com.seven.ikar.kotlin.core.model.TypingIndicatorDto
import com.seven.ikar.kotlin.core.model.UserSummaryDto
import com.seven.ikar.kotlin.core.network.ServerConfig
import com.seven.ikar.kotlin.core.network.UploadFileSource
import com.seven.ikar.kotlin.core.local.TransferQueueEntity
import com.seven.ikar.kotlin.feature.call.AudioCallSnapshot
import com.seven.ikar.kotlin.feature.call.AudioCallStage
import com.seven.ikar.kotlin.feature.transfer.TransferForegroundNotification
import com.seven.ikar.kotlin.ui.formatPresence
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID

data class PendingAttachmentUiState(
    val fileName: String,
    val contentType: String,
    val fileSizeBytes: Long,
    val imagePreviews: List<PendingImageAttachmentUiState> = emptyList()
)

data class PendingImageAttachmentUiState(
    val fileName: String,
    val localPath: String
)

data class TransferQueueUiState(
    val groupId: String,
    val title: String,
    val status: String,
    val progressPercent: Int,
    val errorMessage: String?
)

data class VoiceAttachmentPlaybackUiState(
    val isPlaying: Boolean = false,
    val positionMs: Long = 0L,
    val durationMs: Long = 0L
)

data class BotQuickAction(
    val title: String,
    val commandText: String
)

data class ChatSearchUiState(
    val isActive: Boolean = false,
    val query: String = "",
    val resultMessageIds: List<String> = emptyList(),
    val activeResultIndex: Int = 0,
    val isSearching: Boolean = false
) {
    val activeMessageId: String?
        get() = resultMessageIds.getOrNull(activeResultIndex)
}

data class ChatScrollRequest(
    val messageId: String?,
    val requestId: Long = System.nanoTime()
)

data class ChatUiState(
    val session: AuthSessionDto? = null,
    val title: String = "",
    val subtitle: String = "",
    val chatId: String = "",
    val chatType: ChatType = ChatType.Direct,
    val peer: UserSummaryDto? = null,
    val botPeer: UserSummaryDto? = null,
    val messages: List<MessageDto> = emptyList(),
    val pinnedMessages: List<MessageDto> = emptyList(),
    val hasMoreMessages: Boolean = false,
    val isLoadingOlderMessages: Boolean = false,
    val draftText: String = "",
    val canSendMessages: Boolean = true,
    val subscriberCount: Int = 0,
    val adminCount: Int = 0,
    val isSubscribed: Boolean = false,
    val canSubscribe: Boolean = false,
    val canManageChannel: Boolean = false,
    val canPostAsChannel: Boolean = false,
    val publicUsername: String? = null,
    val linkedDiscussionChatId: String? = null,
    val channelDescription: String? = null,
    val isJoiningChannel: Boolean = false,
    val isLeavingChannel: Boolean = false,
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val activeCall: AudioCallSnapshot? = null,
    val pendingAttachment: PendingAttachmentUiState? = null,
    val replyTo: MessageReplyDto? = null,
    val editingMessageId: String? = null,
    val editingMessageBanner: String = "",
    val isRecordingVoiceNote: Boolean = false,
    val voiceRecordingElapsedText: String = "00:00",
    val attachmentPlayback: Map<String, VoiceAttachmentPlaybackUiState> = emptyMap(),
    val botCommands: List<BotCommandDto> = emptyList(),
    val previewImageAttachmentId: String? = null,
    val firstUnreadMessageId: String? = null,
    val selectedMessageIds: Set<String> = emptySet(),
    val isDeleteSelectionDialogVisible: Boolean = false,
    val isDeletingSelectedMessages: Boolean = false,
    val sendingLocalMessageIds: Set<String> = emptySet(),
    val failedLocalMessageIds: Set<String> = emptySet(),
    val activeTransfers: List<TransferQueueUiState> = emptyList(),
    val search: ChatSearchUiState = ChatSearchUiState(),
    val scrollRequest: ChatScrollRequest? = null,
    val isSendingMessage: Boolean = false,
    val isDeletingChat: Boolean = false,
    val isChatDeleted: Boolean = false
) {
    val isMessageSelectionMode: Boolean
        get() = selectedMessageIds.isNotEmpty()

    val selectedMessagesCount: Int
        get() = selectedMessageIds.size
}

private val DefaultCodexBridgeBotCommands = listOf(
    BotCommandDto(command = "help", description = "Показать список команд моста"),
    BotCommandDto(command = "status", description = "Показать состояние моста и выбранный thread"),
    BotCommandDto(command = "threads", description = "Показать thread по именам"),
    BotCommandDto(command = "use", description = "Выбрать thread из последнего списка"),
    BotCommandDto(command = "newthread", description = "Создать новый thread"),
    BotCommandDto(command = "turn", description = "Запустить turn в выбранном thread")
)

private val BotThreadOptionRegex = Regex("""^(?<index>\d+)\.\s+(?<title>.+?)(?<selected>\s+\[выбран])?$""")
private val ChatTimestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU"))
private const val LocalMessageIdPrefix = "local-"

class ChatViewModel(
    private val container: AppContainer,
    private val chatId: String
) : ViewModel() {
    private val _uiState = MutableStateFlow(ChatUiState(chatId = chatId, session = container.sessionStore.read()))
    val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()

    private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>()
    private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
    private var editingMessage: MessageDto? = null
    private var pendingAttachments: List<LocalAttachmentFile> = emptyList()
    private var baseSubtitle: String = ""
    private val typingUsers = mutableMapOf<String, UserSummaryDto>()
    private val typingExpiryJobs = mutableMapOf<String, Job>()
    private var typingStopJob: Job? = null
    private var searchJob: Job? = null
    private var typingStateSent = false
    private var lastTypingSentAtMillis = 0L

    init {
        attachRealtime()
        attachCallState()
        attachVoicePlaybackState()
        attachVoiceRecordingState()
        attachTransferQueueState()
        loadCachedMessages()
        refresh()
    }

    fun updateDraft(value: String) {
        _uiState.value = _uiState.value.copy(draftText = value)
        notifyTyping(value.isNotBlank())
    }

    override fun onCleared() {
        notifyTyping(false)
        typingExpiryJobs.values.forEach(Job::cancel)
        searchJob?.cancel()
        super.onCleared()
    }

    fun readSavedScrollPosition(): SavedChatScrollPosition? =
        container.chatScrollStateStore.read(chatId)

    fun saveScrollPosition(position: SavedChatScrollPosition) {
        container.chatScrollStateStore.save(chatId, position)
    }

    fun toggleMessageSelection(message: MessageDto) {
        if (!canSelectMessage(message)) {
            return
        }

        val currentState = _uiState.value
        val nextSelectedMessageIds = if (currentState.selectedMessageIds.contains(message.id)) {
            currentState.selectedMessageIds - message.id
        } else {
            currentState.selectedMessageIds + message.id
        }

        _uiState.value = currentState.copy(
            selectedMessageIds = nextSelectedMessageIds,
            isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && nextSelectedMessageIds.isNotEmpty(),
            isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && nextSelectedMessageIds.isNotEmpty()
        )
    }

    fun handleMessageLongPress(message: MessageDto) {
        if (!canSelectMessage(message)) {
            return
        }

        val currentState = _uiState.value
        if (currentState.isMessageSelectionMode) {
            toggleMessageSelection(message)
        } else {
            _uiState.value = currentState.copy(selectedMessageIds = setOf(message.id))
        }
    }

    fun clearMessageSelection() {
        _uiState.value = _uiState.value.copy(
            selectedMessageIds = emptySet(),
            isDeleteSelectionDialogVisible = false,
            isDeletingSelectedMessages = false
        )
    }

    fun showDeleteSelectedMessagesDialog() {
        val state = _uiState.value
        if (state.selectedMessageIds.isEmpty() || state.isDeletingSelectedMessages) {
            return
        }

        _uiState.value = state.copy(isDeleteSelectionDialogVisible = true)
    }

    fun dismissDeleteSelectedMessagesDialog() {
        if (_uiState.value.isDeletingSelectedMessages) {
            return
        }

        _uiState.value = _uiState.value.copy(isDeleteSelectionDialogVisible = false)
    }

    fun confirmDeleteSelectedMessages() {
        val state = _uiState.value
        if (state.selectedMessageIds.isEmpty() || state.isDeletingSelectedMessages) {
            return
        }

        val selectedMessages = state.messages.filter { state.selectedMessageIds.contains(it.id) && canSelectMessage(it) }
        if (selectedMessages.isEmpty()) {
            clearMessageSelection()
            return
        }

        viewModelScope.launch {
            _uiState.value = state.copy(isDeletingSelectedMessages = true, errorMessage = null)
            runCatching {
                container.executeAuthorized { session ->
                    selectedMessages.map { message ->
                        container.apiClient.deleteMessage(session.accessToken, chatId, message.id)
                    }
                }
            }.onSuccess { updatedMessages ->
                updatedMessages.forEach { updated ->
                    if (editingMessage?.id == updated.id) {
                        cancelEditMessageInternal(clearDraft = true)
                    }
                    replaceOrInsertMessage(updated)
                }
                clearMessageSelection()
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    isDeletingSelectedMessages = false,
                    errorMessage = error.message
                )
            }
        }
    }

    fun appendEmoji(emoji: String) {
        _uiState.value = _uiState.value.copy(draftText = _uiState.value.draftText + emoji)
    }

    fun clearErrorMessage() {
        _uiState.value = _uiState.value.copy(errorMessage = null)
    }

    fun setErrorMessage(message: String?) {
        _uiState.value = _uiState.value.copy(errorMessage = message)
    }

    fun openMessageSearch() {
        val state = _uiState.value
        _uiState.value = state.copy(
            selectedMessageIds = emptySet(),
            isDeleteSelectionDialogVisible = false,
            search = state.search.copy(isActive = true)
        )
    }

    fun closeMessageSearch() {
        searchJob?.cancel()
        _uiState.value = _uiState.value.copy(search = ChatSearchUiState())
    }

    fun jumpToMessage(messageId: String) {
        if (messageId.isBlank()) {
            return
        }

        _uiState.value = _uiState.value.copy(
            selectedMessageIds = emptySet(),
            isDeleteSelectionDialogVisible = false,
            search = ChatSearchUiState(
                isActive = true,
                resultMessageIds = listOf(messageId),
                activeResultIndex = 0,
                isSearching = false
            )
        )
    }

    fun requestScrollToMessage(messageId: String) {
        if (messageId.isBlank()) {
            return
        }
        requestScrollToMessageInternal(messageId)
    }

    fun requestScrollToLatest() {
        requestScrollToMessageInternal(messageId = null)
    }

    fun consumeScrollRequest(requestId: Long) {
        val current = _uiState.value.scrollRequest ?: return
        if (current.requestId == requestId) {
            _uiState.value = _uiState.value.copy(scrollRequest = null)
        }
    }

    fun updateMessageSearchQuery(value: String) {
        searchJob?.cancel()
        val localResultIds = searchLoadedMessageIds(value, _uiState.value.messages)
        _uiState.value = _uiState.value.copy(
            search = ChatSearchUiState(
                isActive = true,
                query = value,
                resultMessageIds = localResultIds,
                activeResultIndex = 0,
                isSearching = value.trim().isNotBlank()
            )
        )

        val query = value.trim()
        if (query.isBlank()) {
            _uiState.value = _uiState.value.copy(search = _uiState.value.search.copy(isSearching = false))
            return
        }

        searchJob = viewModelScope.launch {
            delay(250)
            runCatching {
                container.executeAuthorized { session ->
                    session to container.apiClient.searchChatMessages(
                        accessToken = session.accessToken,
                        chatId = chatId,
                        query = query,
                        limit = 100
                    )
                }
            }.onSuccess { (session, remoteMessages) ->
                val currentState = _uiState.value
                if (currentState.search.query.trim() != query) {
                    return@onSuccess
                }

                val mergedMessages = (currentState.messages + remoteMessages)
                    .distinctBy { it.id }
                    .sortedBy { it.sentAt }
                val resultMessageIds = remoteMessages
                    .filterNot { it.isDeleted() }
                    .sortedByDescending { it.sentAt }
                    .map(MessageDto::id)
                    .ifEmpty { searchLoadedMessageIds(query, mergedMessages) }

                _uiState.value = currentState.copy(
                    messages = mergedMessages,
                    search = currentState.search.copy(
                        resultMessageIds = resultMessageIds,
                        activeResultIndex = 0,
                        isSearching = false
                    )
                )
                cacheMessages(session.user.id, remoteMessages)
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    search = _uiState.value.search.copy(isSearching = false),
                    errorMessage = error.message
                )
            }
        }
    }

    fun navigateMessageSearch(delta: Int) {
        val state = _uiState.value
        val size = state.search.resultMessageIds.size
        if (size == 0) {
            return
        }

        val nextIndex = (state.search.activeResultIndex + delta).floorMod(size)
        _uiState.value = state.copy(search = state.search.copy(activeResultIndex = nextIndex))
    }

    fun refresh() {
        if (container.sessionStore.read() == null) return
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
            runCatching {
                container.executeAuthorized { session ->
                    container.realtimeService.connect(session.accessToken)
                    session to container.apiClient.getChat(chatId, session.accessToken)
                }
            }.onSuccess { (session, chat) ->
                contactNamesByNormalizedPhone = loadDeviceContactNames()
                applyChat(chat, session)
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
            }
        }
    }

    fun loadOlderMessages() {
        val state = _uiState.value
        val oldestMessageId = state.messages.firstOrNull()?.id
        if (
            oldestMessageId == null ||
            !state.hasMoreMessages ||
            state.isLoadingOlderMessages ||
            state.isLoading
        ) {
            return
        }

        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoadingOlderMessages = true, errorMessage = null)
            runCatching {
                container.executeAuthorized { session ->
                    session to container.apiClient.getChatMessagesPage(
                        accessToken = session.accessToken,
                        chatId = chatId,
                        beforeMessageId = oldestMessageId
                    )
                }
            }.onSuccess { (session, page) ->
                val currentState = _uiState.value
                val mergedMessages = (page.messages + currentState.messages)
                    .distinctBy { it.id }
                    .sortedBy { it.sentAt }
                val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, mergedMessages)

                _uiState.value = currentState.copy(
                    messages = mergedMessages,
                    hasMoreMessages = page.hasMoreMessages,
                    isLoadingOlderMessages = false,
                    selectedMessageIds = selectedMessageIds,
                    isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(),
                    isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty()
                )
                cacheMessages(session.user.id, page.messages)
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    isLoadingOlderMessages = false,
                    errorMessage = error.message
                )
            }
        }
    }

    fun sendMessage() {
        val state = _uiState.value
        if (!state.canSendMessages || state.isSendingMessage) {
            return
        }

        val attachments = pendingAttachments
        val messageBeingEdited = editingMessage
        val draftText = state.draftText.trim()
        val replyToMessageId = state.replyTo?.messageId
        if (attachments.isEmpty() && messageBeingEdited == null && draftText.isBlank()) {
            return
        }

        notifyTyping(false)

        if (attachments.isNotEmpty()) {
            _uiState.value = state.copy(isSendingMessage = true, errorMessage = null)
            viewModelScope.launch {
                runCatching {
                    container.transferQueueRepository.enqueueUpload(
                        chatId = chatId,
                        text = draftText.takeIf { it.isNotBlank() },
                        replyToMessageId = replyToMessageId,
                        files = attachments
                    )
                }.onSuccess {
                    clearPendingAttachment()
                    _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
                }.onFailure { error ->
                    _uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message)
                }
            }
            return
        }

        val optimisticMessage = if (messageBeingEdited == null && draftText.isNotBlank()) {
            buildOptimisticMessage(state, draftText)
        } else {
            null
        }

        if (optimisticMessage != null) {
            val optimisticMessages = (state.messages + optimisticMessage)
                .distinctBy { it.id }
                .sortedBy { it.sentAt }
            _uiState.value = state.copy(
                messages = optimisticMessages,
                draftText = "",
                replyTo = null,
                isSendingMessage = false,
                errorMessage = null,
                sendingLocalMessageIds = state.sendingLocalMessageIds + optimisticMessage.id,
                failedLocalMessageIds = state.failedLocalMessageIds - optimisticMessage.id
            )
            cacheMessage(optimisticMessage, isOptimistic = true)
        } else {
            _uiState.value = state.copy(isSendingMessage = true, errorMessage = null)
        }

        viewModelScope.launch {
            runCatching {
                when {
                    messageBeingEdited != null -> {
                        container.executeAuthorized { session ->
                            container.apiClient.updateMessage(
                                accessToken = session.accessToken,
                                chatId = chatId,
                                messageId = messageBeingEdited.id,
                                text = draftText
                            )
                        }
                    }

                    draftText.isNotBlank() -> {
                        container.executeAuthorized { session ->
                            container.apiClient.sendMessage(
                                chatId = chatId,
                                text = draftText,
                                accessToken = session.accessToken,
                                replyToMessageId = replyToMessageId
                            )
                        }
                    }

                    else -> null
                }
            }.onSuccess { message ->
                val localMessageId = optimisticMessage?.id
                when {
                    message == null -> {
                        _uiState.value = _uiState.value.copy(isSendingMessage = false)
                    }

                    messageBeingEdited != null -> {
                        cancelEditMessageInternal(clearDraft = true)
                        _uiState.value = _uiState.value.copy(isSendingMessage = false)
                        replaceOrInsertMessage(message)
                    }

                    else -> {
                        if (localMessageId != null) {
                            replaceLocalMessage(localMessageId, message)
                        } else {
                            _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
                            replaceOrInsertMessage(message)
                        }
                    }
                }
            }.onFailure { error ->
                val localMessageId = optimisticMessage?.id
                if (localMessageId != null) {
                    val currentState = _uiState.value
                    _uiState.value = currentState.copy(
                        errorMessage = error.message,
                        sendingLocalMessageIds = currentState.sendingLocalMessageIds - localMessageId,
                        failedLocalMessageIds = currentState.failedLocalMessageIds + localMessageId
                    )
                } else {
                    _uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message)
                }
            }
        }
    }

    fun ensureBotCommandsLoaded() {
        val state = _uiState.value
        val botPeer = state.botPeer ?: state.peer?.takeIf { it.isBot }
        if (state.chatType != ChatType.Direct || botPeer == null) {
            return
        }

        if (state.botCommands.isNotEmpty()) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.getBotCommands(session.accessToken, botPeer.id)
                }
            }.onSuccess { commands ->
                _uiState.value = _uiState.value.copy(
                    botCommands = commands.ifEmpty { DefaultCodexBridgeBotCommands }
                )
            }.onFailure {
                _uiState.value = _uiState.value.copy(botCommands = DefaultCodexBridgeBotCommands)
            }
        }
    }

    fun executeBotCommand(commandText: String) {
        if (commandText.isBlank()) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.sendMessage(chatId, commandText.trim(), session.accessToken)
                }
            }.onSuccess { message ->
                if (commandText.startsWith("/turn ", ignoreCase = true)) {
                    _uiState.value = _uiState.value.copy(draftText = "")
                }
                replaceOrInsertMessage(message)
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun clearChatHistory() {
        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.clearChatHistory(session.accessToken, chatId)
                    session.user.id
                }
            }.onSuccess { ownerUserId ->
                clearPendingAttachment()
                cancelEditMessageInternal(clearDraft = true)
                container.chatScrollStateStore.clear(chatId)
                clearCachedChat(ownerUserId)
                _uiState.value = _uiState.value.copy(
                    messages = emptyList(),
                    pinnedMessages = emptyList(),
                    hasMoreMessages = false,
                    isLoadingOlderMessages = false,
                    errorMessage = null,
                    replyTo = null,
                    selectedMessageIds = emptySet(),
                    isDeleteSelectionDialogVisible = false,
                    isDeletingSelectedMessages = false,
                    sendingLocalMessageIds = emptySet(),
                    failedLocalMessageIds = emptySet()
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun deleteChat() {
        val state = _uiState.value
        if (state.isDeletingChat || state.isChatDeleted) {
            return
        }

        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isDeletingChat = true, errorMessage = null)
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.deleteChat(
                        accessToken = session.accessToken,
                        chatId = chatId,
                        forEveryone = false
                    )
                    session.user.id
                }
            }.onSuccess { ownerUserId ->
                clearPendingAttachment()
                cancelEditMessageInternal(clearDraft = true)
                container.chatScrollStateStore.clear(chatId)
                container.pushSettings.resetChatNotificationPreferences(chatId)
                clearCachedChat(ownerUserId)
                _uiState.value = _uiState.value.copy(
                    isDeletingChat = false,
                    isChatDeleted = true,
                    errorMessage = null
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    isDeletingChat = false,
                    errorMessage = error.message
                )
            }
        }
    }

    fun subscribeToChannel() {
        val state = _uiState.value
        if (state.chatType != ChatType.Channel || !state.canSubscribe || state.isJoiningChannel) {
            return
        }

        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isJoiningChannel = true, errorMessage = null)
            runCatching {
                container.executeAuthorized { session ->
                    session to container.apiClient.subscribeToChannel(session.accessToken, chatId)
                }
            }.onSuccess { (session, chat) ->
                applyChat(chat, session)
                requestScrollToLatest()
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(isJoiningChannel = false, errorMessage = error.message)
            }
        }
    }

    fun unsubscribeFromChannel() {
        val state = _uiState.value
        if (state.chatType != ChatType.Channel || !state.isSubscribed || state.canManageChannel || state.isLeavingChannel) {
            return
        }

        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLeavingChannel = true, errorMessage = null)
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.unsubscribeFromChannel(session.accessToken, chatId)
                    session.user.id
                }
            }.onSuccess { ownerUserId ->
                clearPendingAttachment()
                cancelEditMessageInternal(clearDraft = true)
                container.chatScrollStateStore.clear(chatId)
                container.pushSettings.resetChatNotificationPreferences(chatId)
                clearCachedChat(ownerUserId)
                _uiState.value = _uiState.value.copy(
                    isLeavingChannel = false,
                    isChatDeleted = true,
                    errorMessage = null
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(isLeavingChannel = false, errorMessage = error.message)
            }
        }
    }

    fun beginEditMessage(message: MessageDto) {
        if (!message.canManage(currentUserId = _uiState.value.session?.user?.id)) {
            return
        }

        clearPendingAttachment()
        cancelReplyInternal()
        editingMessage = message
        _uiState.value = _uiState.value.copy(
            editingMessageId = message.id,
            editingMessageBanner = "Сообщение от ${formatTimestamp(message.sentAt)}",
            draftText = message.text
        )
    }

    fun cancelEditMessage() {
        cancelEditMessageInternal(clearDraft = true)
    }

    fun beginReplyToMessage(message: MessageDto) {
        if (message.isDeleted()) {
            return
        }

        val wasEditing = editingMessage != null
        cancelEditMessageInternal(clearDraft = wasEditing)
        _uiState.value = _uiState.value.copy(replyTo = message.toReplyPreview())
    }

    fun cancelReply() {
        cancelReplyInternal()
    }

    fun toggleReaction(message: MessageDto, emoji: String) {
        if (message.isDeleted() || emoji.isBlank()) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.setMessageReaction(
                        accessToken = session.accessToken,
                        chatId = chatId,
                        messageId = message.id,
                        emoji = emoji
                    )
                }
            }.onSuccess(::replaceOrInsertMessage)
                .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) }
        }
    }

    fun toggleReactionFromSelection(message: MessageDto, emoji: String) {
        if (message.isDeleted() || emoji.isBlank()) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.setMessageReaction(
                        accessToken = session.accessToken,
                        chatId = chatId,
                        messageId = message.id,
                        emoji = emoji
                    )
                }
            }.onSuccess { updatedMessage ->
                replaceOrInsertMessage(updatedMessage)
                clearMessageSelection()
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun deleteMessage(message: MessageDto) {
        if (!message.canDelete(currentUserId = _uiState.value.session?.user?.id)) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.deleteMessage(session.accessToken, chatId, message.id)
                }
            }.onSuccess { updated ->
                if (editingMessage?.id == updated.id) {
                    cancelEditMessageInternal(clearDraft = true)
                }
                replaceOrInsertMessage(updated)
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun togglePinMessage(message: MessageDto) {
        if (message.isLocalMessage() || message.isDeleted()) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    if (message.pinnedAt == null) {
                        container.apiClient.pinMessage(session.accessToken, chatId, message.id)
                    } else {
                        container.apiClient.unpinMessage(session.accessToken, chatId, message.id)
                    }
                }
            }.onSuccess(::replaceOrInsertMessage)
                .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) }
        }
    }

    fun consumeAttachmentUri(uri: Uri) {
        consumeAttachmentUris(listOf(uri))
    }

    fun consumeAttachmentUris(uris: List<Uri>) {
        if (uris.isEmpty()) {
            return
        }

        viewModelScope.launch {
            runCatching {
                uris.map { uri -> container.attachmentFileService.importFromUri(uri) }
            }.onSuccess { attachments ->
                cancelEditMessageInternal(clearDraft = false)
                pendingAttachments = attachments
                _uiState.value = _uiState.value.copy(
                    pendingAttachment = attachments.toPendingAttachmentUiState(),
                    errorMessage = null
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun removePendingAttachment() {
        clearPendingAttachment()
    }

    fun retryTransfer(groupId: String) {
        viewModelScope.launch {
            runCatching { container.transferQueueRepository.retry(groupId) }
                .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) }
        }
    }

    fun cancelTransfer(groupId: String) {
        viewModelScope.launch {
            runCatching { container.transferQueueRepository.cancel(groupId) }
                .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) }
        }
    }

    fun openAttachment(attachment: AttachmentDto) {
        if (attachment.isImageAttachment()) {
            showImagePreviewAttachment(attachment.id)
            return
        }

        if (attachment.kind == com.seven.ikar.kotlin.core.model.AttachmentKind.VoiceNote) {
            toggleVoiceAttachmentPlayback(attachment)
            return
        }

        viewModelScope.launch {
            runCatching {
                val local = ensureAttachmentDownloaded(attachment)
                container.attachmentFileService.openCachedAttachment(
                    localPath = local.localPath,
                    fileName = local.fileName,
                    contentType = local.contentType
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun dismissImagePreview() {
        _uiState.value = _uiState.value.copy(previewImageAttachmentId = null)
    }

    fun showImagePreviewAttachment(attachmentId: String) {
        _uiState.value = _uiState.value.copy(previewImageAttachmentId = attachmentId)
    }

    fun shareAttachmentExternally(attachment: AttachmentDto) {
        if (isAttachmentInProtectedMessage(attachment)) {
            _uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.")
            return
        }

        viewModelScope.launch {
            runCatching {
                val local = ensureAttachmentDownloaded(attachment)
                container.attachmentFileService.shareCachedAttachment(
                    localPath = local.localPath,
                    fileName = local.fileName,
                    contentType = local.contentType
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun saveImageAttachmentToGallery(attachment: AttachmentDto) {
        if (isAttachmentInProtectedMessage(attachment)) {
            _uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя сохранять.")
            return
        }

        viewModelScope.launch {
            runCatching {
                val local = ensureAttachmentDownloaded(attachment)
                container.attachmentFileService.saveCachedImageToGallery(
                    localPath = local.localPath,
                    fileName = local.fileName,
                    contentType = local.contentType
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun beginVoiceRecording() {
        if (_uiState.value.isRecordingVoiceNote || !_uiState.value.canSendMessages) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.voiceNoteRecorderService.start()
            }.onSuccess {
                _uiState.value = _uiState.value.copy(
                    isRecordingVoiceNote = true,
                    voiceRecordingElapsedText = "00:00",
                    errorMessage = null
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    fun completeVoiceRecording() {
        if (!_uiState.value.isRecordingVoiceNote) {
            return
        }

        viewModelScope.launch {
            runCatching {
                val file = container.voiceNoteRecorderService.stop(discard = false)
                if (file == null) {
                    null
                } else {
                    if (file.length() <= 0L) {
                        file.delete()
                        throw IllegalStateException("Голосовое сообщение пустое.")
                    }

                    if (_uiState.value.voiceRecordingElapsedText == "00:00") {
                        file.delete()
                        throw IllegalStateException("Голосовое сообщение слишком короткое.")
                    }

                    try {
                        container.executeAuthorized { session ->
                            container.apiClient.uploadAttachment(
                                accessToken = session.accessToken,
                                chatId = chatId,
                                text = null,
                                replyToMessageId = _uiState.value.replyTo?.messageId,
                                file = UploadFileSource(
                                    fileName = file.name,
                                    contentType = "audio/mp4",
                                    contentLength = file.length()
                                ) { file.inputStream() }
                            )
                        }
                    } finally {
                        file.delete()
                    }
                }
            }.onSuccess { message ->
                _uiState.value = _uiState.value.copy(
                    isRecordingVoiceNote = false,
                    voiceRecordingElapsedText = "00:00",
                    replyTo = null
                )
                message?.let(::replaceOrInsertMessage)
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    isRecordingVoiceNote = false,
                    voiceRecordingElapsedText = "00:00",
                    errorMessage = error.message
                )
            }
        }
    }

    fun cancelVoiceRecording() {
        viewModelScope.launch {
            runCatching { container.voiceNoteRecorderService.stop(discard = true) }
            _uiState.value = _uiState.value.copy(
                isRecordingVoiceNote = false,
                voiceRecordingElapsedText = "00:00"
            )
        }
    }

    fun stopVoicePlayback() {
        viewModelScope.launch {
            container.voiceNotePlayerService.stop()
        }
    }

    fun startAudioCall() {
        val state = _uiState.value
        if (!canStartAudioCall(state)) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.realtimeService.ensureConnected(session.accessToken)
                    container.callManager.startOutgoingAudioCall(chatId)
                }
            }
                .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) }
        }
    }

    fun startVideoCall() {
        val state = _uiState.value
        if (!canStartAudioCall(state)) {
            return
        }

        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.realtimeService.ensureConnected(session.accessToken)
                    container.callManager.startOutgoingVideoCall(chatId)
                }
            }
                .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) }
        }
    }

    fun acceptCall() {
        container.callManager.acceptCurrentCall()
    }

    fun declineCall() {
        container.callManager.declineCurrentCall()
    }

    fun endCall() {
        container.callManager.endCurrentCall()
    }

    fun attachmentPreviewUrl(attachment: AttachmentDto): String = when {
        attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath
        else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath)
    }

    fun showCallHeaderButton(state: ChatUiState = uiState.value): Boolean =
        state.chatType == ChatType.Direct && state.peer?.isBot == false && state.botPeer == null

    fun showBotComposerMenuButton(state: ChatUiState = uiState.value): Boolean =
        state.chatType == ChatType.Direct && (state.botPeer != null || state.peer?.isBot == true)

    fun showHeaderMenuButton(state: ChatUiState = uiState.value): Boolean =
        state.chatType == ChatType.Direct && (state.botPeer != null || state.peer?.isBot == true)

    fun showAcceptCallButton(state: ChatUiState = uiState.value): Boolean =
        state.activeCall?.stage == AudioCallStage.Incoming

    fun showDeclineCallButton(state: ChatUiState = uiState.value): Boolean =
        state.activeCall?.stage in setOf(AudioCallStage.Incoming, AudioCallStage.Outgoing, AudioCallStage.Connecting)

    fun showEndCallButton(state: ChatUiState = uiState.value): Boolean =
        state.activeCall?.stage == AudioCallStage.Active

    fun callBannerTitle(state: ChatUiState = uiState.value): String =
        state.activeCall?.peer?.displayName.orEmpty()

    fun callBannerSubtitle(state: ChatUiState = uiState.value): String {
        val call = state.activeCall ?: return ""
        val callPrefix = if (call.mediaType == CallMediaType.Video) "Видеозвонок" else "Аудиозвонок"
        return when (call.stage) {
            AudioCallStage.Incoming -> "Входящий ${callPrefix.lowercase()}"
            AudioCallStage.Outgoing -> if (call.mediaType == CallMediaType.Video) "Идёт видеовызов..." else "Идёт вызов..."
            AudioCallStage.Connecting -> "Подключение..."
            AudioCallStage.Active -> if (call.mediaType == CallMediaType.Video) "Видеозвонок ${call.durationText}" else "Разговор ${call.durationText}"
            AudioCallStage.None -> ""
        }
    }

    fun availableBotCommands(state: ChatUiState = uiState.value): List<BotCommandDto> =
        state.botCommands.ifEmpty { DefaultCodexBridgeBotCommands }

    fun latestBotQuickActions(state: ChatUiState = uiState.value): List<BotQuickAction> =
        state.messages.lastOrNull { buildBotQuickActions(it).isNotEmpty() }
            ?.let(::buildBotQuickActions)
            .orEmpty()

    private fun loadCachedMessages(session: AuthSessionDto? = container.sessionStore.read()) {
        if (session == null) {
            return
        }

        viewModelScope.launch {
            val cachedMessages = withContext(Dispatchers.IO) {
                val latestMessages = container.chatMessageCache.loadLatestMessages(session.user.id, chatId)
                val optimisticMessages = container.chatMessageCache.loadOptimisticMessages(session.user.id, chatId)
                (latestMessages + optimisticMessages)
                    .distinctBy { it.id }
                    .sortedBy { it.sentAt }
            }
            if (cachedMessages.isEmpty()) {
                return@launch
            }

            val currentState = _uiState.value
            val mergedMessages = (currentState.messages + cachedMessages)
                .distinctBy { it.id }
                .sortedBy { it.sentAt }
            val cachedLocalMessageIds = cachedMessages
                .filter { it.isLocalMessage() }
                .mapTo(mutableSetOf()) { it.id }
            val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, mergedMessages)

            _uiState.value = currentState.copy(
                session = session,
                messages = mergedMessages,
                selectedMessageIds = selectedMessageIds,
                isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(),
                isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(),
                failedLocalMessageIds = currentState.failedLocalMessageIds +
                    (cachedLocalMessageIds - currentState.sendingLocalMessageIds)
            )
        }
    }

    private fun buildOptimisticMessage(state: ChatUiState, text: String): MessageDto? {
        val session = state.session ?: container.sessionStore.read() ?: return null
        return MessageDto(
            id = "$LocalMessageIdPrefix${UUID.randomUUID()}",
            chatId = chatId,
            chatType = state.chatType,
            sender = session.user,
            text = text,
            attachments = emptyList(),
            sentAt = OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
            deliveryState = MessageDeliveryState.Sent,
            replyTo = state.replyTo,
            postedAsChannel = state.chatType == ChatType.Channel,
            displayAuthorName = if (state.chatType == ChatType.Channel) state.title else session.user.displayName,
            viewCount = if (state.chatType == ChatType.Channel) 0 else null
        )
    }

    private fun applyChat(chat: ChatDetailsDto, session: AuthSessionDto) {
        val previousState = _uiState.value
        val localMessages = previousState.messages.filter { it.isLocalMessage() }
        val orderedMessages = (chat.messages + localMessages)
            .distinctBy { it.id }
            .sortedBy { it.sentAt }
        val localMessageIds = orderedMessages
            .filter { it.isLocalMessage() }
            .mapTo(mutableSetOf()) { it.id }
        val peer = resolvePeer(chat, session)
        val botPeer = resolveBotPeer(chat, session, peer, orderedMessages)
        val resolvedTitle = resolveChatTitle(chat, peer)
        baseSubtitle = buildSubtitle(chat, peer)
        val updatedEditingMessage = previousState.editingMessageId?.let { editingId ->
            orderedMessages.firstOrNull { it.id == editingId && it.deletedAt == null }
        }
        val selectedMessageIds = sanitizeSelectedMessageIds(previousState.selectedMessageIds, orderedMessages)
        editingMessage = updatedEditingMessage

        _uiState.value = previousState.copy(
            session = session,
            title = resolvedTitle,
            subtitle = buildTypingSubtitle().ifBlank { baseSubtitle },
            chatType = chat.type,
            peer = peer,
            botPeer = botPeer,
            messages = orderedMessages,
            pinnedMessages = chat.pinnedMessages,
            hasMoreMessages = chat.hasMoreMessages,
            isLoadingOlderMessages = false,
            firstUnreadMessageId = chat.firstUnreadMessageId,
            canSendMessages = chat.canSendMessages,
            subscriberCount = chat.subscriberCount,
            adminCount = chat.adminCount,
            isSubscribed = chat.isSubscribed,
            canSubscribe = chat.canSubscribe,
            canManageChannel = chat.canManageChannel,
            canPostAsChannel = chat.canPostAsChannel,
            publicUsername = chat.publicUsername,
            linkedDiscussionChatId = chat.linkedDiscussionChatId,
            channelDescription = chat.description,
            isJoiningChannel = false,
            isLeavingChannel = false,
            isLoading = false,
            errorMessage = null,
            selectedMessageIds = selectedMessageIds,
            isDeleteSelectionDialogVisible = previousState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(),
            isDeletingSelectedMessages = previousState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(),
            sendingLocalMessageIds = previousState.sendingLocalMessageIds.intersect(localMessageIds),
            failedLocalMessageIds = previousState.failedLocalMessageIds.intersect(localMessageIds),
            editingMessageId = updatedEditingMessage?.id,
            editingMessageBanner = updatedEditingMessage?.let { "Сообщение от ${formatTimestamp(it.sentAt)}" }.orEmpty(),
            draftText = when {
                updatedEditingMessage != null -> previousState.draftText
                previousState.editingMessageId != null -> ""
                else -> previousState.draftText
            }
        )
        cacheMessages(session.user.id, chat.messages)
        scheduleAutoDownloads(orderedMessages)
        refreshCallBanner()
    }

    private fun attachRealtime() {
        viewModelScope.launch {
            container.realtimeService.messagesCreated.collect { message ->
                if (message.chatId == chatId) {
                    replaceOrInsertMessage(message)
                    requestScrollToMessageInternal(message.id)
                    if (message.sender.id != _uiState.value.session?.user?.id) {
                        acknowledgeRead(message.id)
                    }
                }
            }
        }
        viewModelScope.launch {
            container.realtimeService.messagesUpdated.collect { message ->
                if (message.chatId == chatId) {
                    if (editingMessage?.id == message.id && message.deletedAt != null) {
                        cancelEditMessageInternal(clearDraft = true)
                    }
                    replaceOrInsertMessage(message)
                }
            }
        }
        viewModelScope.launch {
            container.realtimeService.chatsChanged.collect {
                refresh()
            }
        }
        viewModelScope.launch {
            container.realtimeService.typingChanged.collect(::handleTypingChanged)
        }
    }

    private fun handleTypingChanged(payload: TypingIndicatorDto) {
        val currentUserId = _uiState.value.session?.user?.id
        if (payload.chatId != chatId || payload.user.id == currentUserId) {
            return
        }

        val userId = payload.user.id
        typingExpiryJobs.remove(userId)?.cancel()
        if (payload.isTyping) {
            typingUsers[userId] = payload.user
            typingExpiryJobs[userId] = viewModelScope.launch {
                delay(4_000)
                typingUsers.remove(userId)
                typingExpiryJobs.remove(userId)
                updateTypingSubtitle()
            }
        } else {
            typingUsers.remove(userId)
        }

        updateTypingSubtitle()
    }

    private fun notifyTyping(isTyping: Boolean) {
        if (!_uiState.value.canSendMessages) {
            return
        }

        if (!isTyping) {
            typingStopJob?.cancel()
            typingStopJob = null
            if (typingStateSent) {
                typingStateSent = false
                sendTypingSignal(false)
            }
            return
        }

        val now = System.currentTimeMillis()
        if (!typingStateSent || now - lastTypingSentAtMillis > 2_000) {
            typingStateSent = true
            lastTypingSentAtMillis = now
            sendTypingSignal(true)
        }

        typingStopJob?.cancel()
        typingStopJob = viewModelScope.launch {
            delay(2_500)
            typingStateSent = false
            sendTypingSignal(false)
        }
    }

    private fun sendTypingSignal(isTyping: Boolean) {
        viewModelScope.launch(Dispatchers.IO) {
            runCatching {
                container.executeAuthorized { session ->
                    container.realtimeService.ensureConnected(session.accessToken)
                    container.realtimeService.sendTyping(chatId, isTyping)
                }
            }
        }
    }

    private fun updateTypingSubtitle() {
        _uiState.value = _uiState.value.copy(
            subtitle = buildTypingSubtitle().ifBlank { baseSubtitle }
        )
    }

    private fun buildTypingSubtitle(): String {
        val names = typingUsers.values
            .sortedBy { it.displayName.lowercase(Locale.getDefault()) }
            .map { it.displayName.ifBlank { it.username } }

        return when {
            names.isEmpty() -> ""
            names.size == 1 && _uiState.value.chatType == ChatType.Direct -> "печатает..."
            names.size == 1 -> "${names.first()} печатает..."
            names.size == 2 -> "${names[0]} и ${names[1]} печатают..."
            else -> "${names.size} участников печатают..."
        }
    }

    private fun attachCallState() {
        viewModelScope.launch {
            container.callManager.currentCall.collect {
                refreshCallBanner()
            }
        }
    }

    private fun attachVoicePlaybackState() {
        viewModelScope.launch {
            container.voiceNotePlayerService.events.collect { event ->
                val nextMap = _uiState.value.attachmentPlayback.toMutableMap()
                nextMap[event.attachmentId] = VoiceAttachmentPlaybackUiState(
                    isPlaying = event.isPlaying,
                    positionMs = event.positionMs,
                    durationMs = event.durationMs
                )
                _uiState.value = _uiState.value.copy(attachmentPlayback = nextMap)
            }
        }
    }

    private fun attachVoiceRecordingState() {
        viewModelScope.launch {
            container.voiceNoteRecorderService.elapsedMs.collect { elapsedMs ->
                if (_uiState.value.isRecordingVoiceNote) {
                    _uiState.value = _uiState.value.copy(
                        voiceRecordingElapsedText = formatDurationMs(elapsedMs)
                    )
                }
            }
        }
    }

    private fun attachTransferQueueState() {
        viewModelScope.launch {
            container.transferQueueRepository.observeActiveForChat(chatId).collect { items ->
                _uiState.value = _uiState.value.copy(activeTransfers = items.toTransferQueueUiState())
            }
        }
    }

    private fun refreshCallBanner() {
        val current = container.callManager.currentCall.value
        _uiState.value = _uiState.value.copy(activeCall = current?.takeIf { it.chatId == chatId })
    }

    private suspend fun ensureAttachmentDownloaded(attachment: AttachmentDto): LocalAttachmentFile {
        cachedAttachmentFiles[attachment.id]?.let { return it }

        val target = container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName)
        if (target.exists()) {
            val cached = LocalAttachmentFile(
                fileName = attachment.fileName,
                contentType = attachment.contentType,
                fileSizeBytes = target.length(),
                localPath = target.absolutePath
            )
            cachedAttachmentFiles[attachment.id] = cached
            return cached
        }

        if (attachment.fileSizeBytes >= TransferForegroundNotification.LargeTransferThresholdBytes) {
            container.transferQueueRepository.enqueueDownload(chatId, attachment)
            throw IllegalStateException("Large download queued. Try opening it again when the transfer completes.")
        }

        return container.executeAuthorized { session ->
            val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target)
            val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded)
            cachedAttachmentFiles[attachment.id] = cached
            cached
        }
    }

    private fun scheduleAutoDownloads(messages: List<MessageDto>) {
        val settings = container.appSettings.loadSnapshot()
        if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) {
            return
        }

        val isUnmetered = isUnmeteredNetwork()
        messages
            .asSequence()
            .filterNot { it.hasProtectedContent || it.isDeleted() }
            .flatMap { it.attachments.asSequence() }
            .filter { attachment -> shouldAutoDownload(attachment, settings, isUnmetered) }
            .filterNot { attachment -> cachedAttachmentFiles.containsKey(attachment.id) }
            .filterNot { attachment -> container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName).exists() }
            .take(8)
            .forEach { attachment ->
                viewModelScope.launch {
                    runCatching { ensureAttachmentDownloaded(attachment) }
                }
            }
    }

    private fun shouldAutoDownload(
        attachment: AttachmentDto,
        settings: com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot,
        isUnmetered: Boolean
    ): Boolean = when {
        attachment.isImageAttachment() -> settings.autoDownloadPhotos
        attachment.contentType.startsWith("video/", ignoreCase = true) -> settings.autoDownloadVideosOnWifi && isUnmetered
        else -> settings.autoDownloadFilesOnWifi && isUnmetered
    }

    private fun isUnmeteredNetwork(): Boolean {
        val connectivityManager = container.appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
            ?: return false
        val network = connectivityManager.activeNetwork ?: return false
        val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
    }

    private fun isAttachmentInProtectedMessage(attachment: AttachmentDto): Boolean =
        _uiState.value.messages.any { message ->
            message.hasProtectedContent && message.attachments.any { it.id == attachment.id }
        }

    private fun toggleVoiceAttachmentPlayback(attachment: AttachmentDto) {
        viewModelScope.launch {
            runCatching {
                val local = ensureAttachmentDownloaded(attachment)
                container.voiceNotePlayerService.toggle(
                    attachmentId = attachment.id,
                    localFilePath = local.localPath
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(errorMessage = error.message)
            }
        }
    }

    private fun clearPendingAttachment() {
        pendingAttachments = emptyList()
        _uiState.value = _uiState.value.copy(pendingAttachment = null)
    }

    private fun cancelEditMessageInternal(clearDraft: Boolean) {
        editingMessage = null
        _uiState.value = _uiState.value.copy(
            editingMessageId = null,
            editingMessageBanner = "",
            draftText = if (clearDraft) "" else _uiState.value.draftText
        )
    }

    private fun cancelReplyInternal() {
        _uiState.value = _uiState.value.copy(replyTo = null)
    }

    private fun replaceOrInsertMessage(message: MessageDto, cacheAsOptimistic: Boolean = message.isLocalMessage()) {
        val currentState = _uiState.value
        val matchingLocalMessageId = findMatchingPendingLocalMessageId(currentState, message)
        val nextMessages = currentState.messages
            .filterNot { it.id == matchingLocalMessageId }
            .toMutableList()
        val existingIndex = nextMessages.indexOfFirst { it.id == message.id }
        if (existingIndex >= 0) {
            nextMessages[existingIndex] = message
        } else {
            nextMessages += message
        }

        val sortedMessages = nextMessages.sortedBy { it.sentAt }
        val updatedReplyTo = currentState.replyTo?.let { currentReply ->
            if (currentReply.messageId == message.id) message.toReplyPreview() else currentReply
        }
        val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, sortedMessages)
        val sendingLocalMessageIds = matchingLocalMessageId?.let { currentState.sendingLocalMessageIds - it }
            ?: currentState.sendingLocalMessageIds
        val failedLocalMessageIds = matchingLocalMessageId?.let { currentState.failedLocalMessageIds - it }
            ?: currentState.failedLocalMessageIds
        val pinnedMessages = updatePinnedMessages(currentState.pinnedMessages, message)

        _uiState.value = currentState.copy(
            messages = sortedMessages,
            pinnedMessages = pinnedMessages,
            errorMessage = null,
            replyTo = updatedReplyTo,
            selectedMessageIds = selectedMessageIds,
            isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(),
            isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(),
            sendingLocalMessageIds = sendingLocalMessageIds,
            failedLocalMessageIds = failedLocalMessageIds
        )
        if (matchingLocalMessageId != null) {
            cacheReplaceOptimisticMessage(matchingLocalMessageId, message)
        } else {
            cacheMessage(message, isOptimistic = cacheAsOptimistic)
        }
        scheduleAutoDownloads(listOf(message))
    }

    private fun requestScrollToMessageInternal(messageId: String?) {
        _uiState.value = _uiState.value.copy(scrollRequest = ChatScrollRequest(messageId = messageId))
    }

    private fun replaceLocalMessage(localMessageId: String, serverMessage: MessageDto) {
        val currentState = _uiState.value
        val nextMessages = currentState.messages
            .filterNot { it.id == localMessageId || it.id == serverMessage.id }
            .plus(serverMessage)
            .sortedBy { it.sentAt }
        val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, nextMessages)
        val pinnedMessages = updatePinnedMessages(currentState.pinnedMessages, serverMessage)

        _uiState.value = currentState.copy(
            messages = nextMessages,
            pinnedMessages = pinnedMessages,
            errorMessage = null,
            selectedMessageIds = selectedMessageIds,
            isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(),
            isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(),
            sendingLocalMessageIds = currentState.sendingLocalMessageIds - localMessageId,
            failedLocalMessageIds = currentState.failedLocalMessageIds - localMessageId
        )
        cacheReplaceOptimisticMessage(localMessageId, serverMessage)
    }

    private fun findMatchingPendingLocalMessageId(state: ChatUiState, serverMessage: MessageDto): String? {
        if (serverMessage.isLocalMessage() || state.sendingLocalMessageIds.isEmpty()) {
            return null
        }

        return state.messages.firstOrNull { localMessage ->
            state.sendingLocalMessageIds.contains(localMessage.id) &&
                localMessage.sender.id == serverMessage.sender.id &&
                localMessage.text == serverMessage.text &&
                localMessage.attachments.isEmpty() &&
                serverMessage.attachments.isEmpty() &&
                localMessage.replyTo?.messageId == serverMessage.replyTo?.messageId
        }?.id
    }

    private fun cacheMessage(message: MessageDto, isOptimistic: Boolean = false) {
        val ownerUserId = _uiState.value.session?.user?.id ?: container.sessionStore.read()?.user?.id ?: return
        cacheMessages(ownerUserId, listOf(message), isOptimistic = isOptimistic)
    }

    private fun cacheMessages(
        ownerUserId: String,
        messages: List<MessageDto>,
        isOptimistic: Boolean = false
    ) {
        if (messages.isEmpty()) {
            return
        }

        viewModelScope.launch(Dispatchers.IO) {
            container.chatMessageCache.upsertMessages(ownerUserId, chatId, messages, isOptimistic)
        }
    }

    private fun cacheReplaceOptimisticMessage(localMessageId: String, serverMessage: MessageDto) {
        val ownerUserId = _uiState.value.session?.user?.id ?: container.sessionStore.read()?.user?.id ?: return
        viewModelScope.launch(Dispatchers.IO) {
            container.chatMessageCache.replaceOptimisticMessage(ownerUserId, chatId, localMessageId, serverMessage)
        }
    }

    private fun clearCachedChat(ownerUserId: String) {
        viewModelScope.launch(Dispatchers.IO) {
            container.chatMessageCache.clearChat(ownerUserId, chatId)
        }
    }

    fun acknowledgeRead(messageId: String) {
        viewModelScope.launch {
            runCatching {
                container.executeAuthorized { session ->
                    container.apiClient.acknowledgeRead(
                        accessToken = session.accessToken,
                        chatId = chatId,
                        messageId = messageId
                    )
                }
            }
        }
    }

    private fun resolvePeer(chat: ChatDetailsDto, session: AuthSessionDto): UserSummaryDto? {
        if (chat.type != ChatType.Direct) {
            return null
        }

        return chat.participants.firstOrNull { it.id != session.user.id }
    }

    private fun resolveBotPeer(
        chat: ChatDetailsDto,
        session: AuthSessionDto,
        peer: UserSummaryDto?,
        messages: List<MessageDto>
    ): UserSummaryDto? {
        if (chat.type != ChatType.Direct) {
            return null
        }

        return peer?.takeIf { it.isBot }
            ?: chat.participants.firstOrNull { it.id != session.user.id && it.isBot }
            ?: messages.asSequence()
                .map { it.sender }
                .firstOrNull { it.id != session.user.id && it.isBot }
    }

    private fun resolveChatTitle(chat: ChatDetailsDto, peer: UserSummaryDto?): String {
        if (chat.type != ChatType.Direct) {
            return chat.title
        }

        val normalizedPhone = PhoneNumberNormalizer.normalize(peer?.phoneNumber)
        val contactName = normalizedPhone?.let(contactNamesByNormalizedPhone::get)
        return contactName?.takeIf { it.isNotBlank() }
            ?: peer?.displayName?.takeIf { it.isNotBlank() }
            ?: chat.title
    }

    private suspend fun loadDeviceContactNames(): Map<String, String> = withContext(Dispatchers.IO) {
        if (!hasContactsPermission()) {
            return@withContext emptyMap()
        }

        runCatching {
            container.contactDiscoveryService
                .getContacts()
                .associate { contact -> contact.normalizedPhoneNumber to contact.displayName }
        }.getOrDefault(emptyMap())
    }

    private fun hasContactsPermission(): Boolean =
        ContextCompat.checkSelfPermission(
            container.appContext,
            Manifest.permission.READ_CONTACTS
        ) == PackageManager.PERMISSION_GRANTED

    private fun canSelectMessage(message: MessageDto): Boolean =
        !message.isLocalMessage() && message.canDelete(currentUserId = _uiState.value.session?.user?.id)

    private fun sanitizeSelectedMessageIds(selectedMessageIds: Set<String>, messages: List<MessageDto>): Set<String> {
        if (selectedMessageIds.isEmpty()) {
            return emptySet()
        }

        val existingSelectableIds = messages
            .asSequence()
            .filter(::canSelectMessage)
            .map(MessageDto::id)
            .toSet()

        return selectedMessageIds.intersect(existingSelectableIds)
    }

    private fun updatePinnedMessages(current: List<MessageDto>, message: MessageDto): List<MessageDto> {
        val withoutMessage = current.filterNot { it.id == message.id }
        if (message.pinnedAt == null || message.isDeleted()) {
            return withoutMessage
        }

        return (withoutMessage + message)
            .sortedByDescending { it.pinnedAt.orEmpty() }
            .take(5)
    }

    private fun buildSubtitle(chat: ChatDetailsDto, peer: UserSummaryDto?): String = when {
        chat.type == ChatType.Direct && peer != null -> formatPresence(peer)
        chat.type == ChatType.Channel && chat.canPostAsChannel -> "${formatChannelSubscribers(chat)} · публикация разрешена"
        chat.type == ChatType.Channel -> formatChannelSubscribers(chat)
        chat.type == ChatType.Group -> "${chat.participants.size} участников"
        else -> "${chat.participants.size} участника"
    }

    private fun canStartAudioCall(state: ChatUiState): Boolean =
        state.chatType == ChatType.Direct &&
            state.peer?.isBot == false &&
            state.activeCall == null
}

internal fun MessageDto.canManage(currentUserId: String?): Boolean =
    currentUserId != null && sender.id == currentUserId && deletedAt == null

internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
    currentUserId != null && deletedAt == null

private fun MessageDto.isLocalMessage(): Boolean = id.startsWith(LocalMessageIdPrefix)

private fun searchLoadedMessageIds(query: String, messages: List<MessageDto>): List<String> {
    val normalizedQuery = query.trim()
    if (normalizedQuery.isBlank()) {
        return emptyList()
    }

    return messages
        .asSequence()
        .filterNot { it.isDeleted() }
        .filter { message ->
            message.text.contains(normalizedQuery, ignoreCase = true) ||
                message.sender.displayName.contains(normalizedQuery, ignoreCase = true) ||
                message.attachments.any { attachment ->
                    attachment.fileName.contains(normalizedQuery, ignoreCase = true) ||
                        attachment.contentType.contains(normalizedQuery, ignoreCase = true)
                }
        }
        .sortedByDescending { it.sentAt }
        .map(MessageDto::id)
        .toList()
}

private fun Int.floorMod(modulus: Int): Int = ((this % modulus) + modulus) % modulus

private fun formatChannelSubscribers(chat: ChatDetailsDto): String {
    val count = if (chat.subscriberCount > 0) chat.subscriberCount else chat.participants.size
    return "$count ${russianPlural(count, "подписчик", "подписчика", "подписчиков")}"
}

private fun russianPlural(count: Int, one: String, few: String, many: String): String {
    val value = kotlin.math.abs(count) % 100
    val lastDigit = value % 10
    if (value in 11..14) {
        return many
    }

    return when (lastDigit) {
        1 -> one
        in 2..4 -> few
        else -> many
    }
}

private fun List<LocalAttachmentFile>.toPendingAttachmentUiState(): PendingAttachmentUiState? {
    if (isEmpty()) {
        return null
    }

    val imagePreviews = filter { it.isImageAttachment() }
        .map { attachment ->
            PendingImageAttachmentUiState(
                fileName = attachment.fileName,
                localPath = attachment.localPath
            )
        }

    if (size == 1) {
        val attachment = first()
        return PendingAttachmentUiState(
            fileName = attachment.fileName,
            contentType = attachment.contentType,
            fileSizeBytes = attachment.fileSizeBytes,
            imagePreviews = imagePreviews
        )
    }

    val allImages = imagePreviews.size == size
    return PendingAttachmentUiState(
        fileName = if (allImages) {
            "$size ${russianPlural(size, "фотография", "фотографии", "фотографий")}"
        } else {
            "$size ${russianPlural(size, "файл", "файла", "файлов")}"
        },
        contentType = if (allImages) "альбом" else "вложения",
        fileSizeBytes = sumOf { it.fileSizeBytes },
        imagePreviews = imagePreviews
    )
}

private fun LocalAttachmentFile.isImageAttachment(): Boolean =
    contentType.startsWith("image/", ignoreCase = true) ||
        fileName.endsWith(".jpg", ignoreCase = true) ||
        fileName.endsWith(".jpeg", ignoreCase = true) ||
        fileName.endsWith(".png", ignoreCase = true) ||
        fileName.endsWith(".webp", ignoreCase = true) ||
        fileName.endsWith(".gif", ignoreCase = true)

private fun List<TransferQueueEntity>.toTransferQueueUiState(): List<TransferQueueUiState> =
    groupBy { it.groupId }
        .map { (groupId, items) ->
            val ordered = items.sortedBy { it.sortOrder }
            val first = ordered.first()
            TransferQueueUiState(
                groupId = groupId,
            title = if (ordered.size == 1) first.fileName else "${ordered.size} файлов",
                status = first.status,
                progressPercent = ordered.maxOfOrNull { it.progressPercent } ?: 0,
                errorMessage = ordered.firstNotNullOfOrNull { it.errorMessage }
            )
        }
        .sortedBy { it.title }

internal fun MessageDto.isDeleted(): Boolean = deletedAt != null

internal fun AttachmentDto.isImageAttachment(): Boolean =
    contentType.startsWith("image/", ignoreCase = true) ||
        fileName.endsWith(".jpg", ignoreCase = true) ||
        fileName.endsWith(".jpeg", ignoreCase = true) ||
        fileName.endsWith(".png", ignoreCase = true) ||
        fileName.endsWith(".webp", ignoreCase = true) ||
        fileName.endsWith(".gif", ignoreCase = true)

internal fun buildBotQuickActions(message: MessageDto): List<BotQuickAction> {
    if (!message.sender.isBot || message.text.isBlank()) {
        return emptyList()
    }

    val normalizedText = message.text.replace("\r\n", "\n")
    if (!normalizedText.startsWith("Текущие thread:") &&
        !normalizedText.startsWith("Thread по запросу")
    ) {
        return if (normalizedText.contains("Создать новый thread:")) {
            listOf(BotQuickAction(title = "Создать новый поток", commandText = "/newthread"))
        } else {
            emptyList()
        }
    }

    return normalizedText
        .split('\n')
        .mapNotNull { rawLine ->
            val match = BotThreadOptionRegex.matchEntire(rawLine.trim()) ?: return@mapNotNull null
            val index = match.groups["index"]?.value?.trim().orEmpty()
            val title = match.groups["title"]?.value?.trim().orEmpty()
            if (index.isBlank() || title.isBlank()) {
                null
            } else {
                BotQuickAction(title = "$index. $title", commandText = "/use $index")
            }
        }
}

internal fun formatDurationMs(value: Long): String {
    val totalSeconds = (value / 1000L).coerceAtLeast(0L)
    val minutes = totalSeconds / 60L
    val seconds = totalSeconds % 60L
    return String.format(Locale.US, "%02d:%02d", minutes, seconds)
}

private fun formatTimestamp(value: String): String =
    runCatching {
        OffsetDateTime.parse(value)
            .atZoneSameInstant(ZoneId.systemDefault())
            .toOffsetDateTime()
            .format(ChatTimestampFormatter)
    }.getOrDefault(value)

private fun MessageDto.toReplyPreview(): MessageReplyDto =
    MessageReplyDto(
        messageId = id,
        senderId = sender.id,
        senderDisplayName = sender.displayName,
        previewText = when {
            isDeleted() -> "Сообщение удалено"
            text.isNotBlank() && attachments.isNotEmpty() -> "$text (${attachments.size})"
            text.isNotBlank() -> text
            attachments.size == 1 -> attachments.first().fileName
            attachments.isNotEmpty() -> "${attachments.size} файлов"
            else -> "Сообщение"
        }
    )
