package com.rosetta.messenger.ui.chats

import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.SystemClock
import android.util.Base64
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.domain.chats.usecase.EncodeVideoUriToHexUseCase
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentUseCase
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentUseCase
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentUseCase
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentUseCase
import com.rosetta.messenger.domain.chats.usecase.ResolveVideoCircleMetaUseCase
import com.rosetta.messenger.domain.chats.usecase.SendReadReceiptCommand
import com.rosetta.messenger.domain.chats.usecase.SendReadReceiptUseCase
import com.rosetta.messenger.domain.chats.usecase.SendForwardUseCase
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageUseCase
import com.rosetta.messenger.domain.chats.usecase.SendTextMessageCommand
import com.rosetta.messenger.domain.chats.usecase.SendTextMessageUseCase
import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorCommand
import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorUseCase
import com.rosetta.messenger.domain.chats.usecase.SendVoiceMessageCommand
import com.rosetta.messenger.domain.chats.usecase.SendVoiceMessageUseCase
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.*
import com.rosetta.messenger.ui.components.metaball.DevicePerformanceClass
import com.rosetta.messenger.ui.components.metaball.PerformanceClass
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.MessageLogger
import java.io.File
import java.util.Date
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.json.JSONArray
import org.json.JSONObject
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

/**
 * ViewModel для экрана чата - оптимизированная версия 🚀 Особенности:
 * - Dispatchers.IO для всех тяжёлых операций
 * - Пагинация сообщений
 * - Chunked decryption (расшифровка пачками)
 * - Кэширование расшифрованных сообщений
 * - Flow для реактивных обновлений без блокировки UI
 */
@HiltViewModel
class ChatViewModel @Inject constructor(
        private val app: Application,
        private val protocolGateway: ProtocolGateway,
        private val messageRepository: MessageRepository,
        private val sendTextMessageUseCase: SendTextMessageUseCase,
        private val sendMediaMessageUseCase: SendMediaMessageUseCase,
        private val sendForwardUseCase: SendForwardUseCase,
        private val sendVoiceMessageUseCase: SendVoiceMessageUseCase,
        private val sendTypingIndicatorUseCase: SendTypingIndicatorUseCase,
        private val sendReadReceiptUseCase: SendReadReceiptUseCase,
        private val createFileAttachmentUseCase: CreateFileAttachmentUseCase,
        private val createAvatarAttachmentUseCase: CreateAvatarAttachmentUseCase,
        private val createVideoCircleAttachmentUseCase: CreateVideoCircleAttachmentUseCase,
        private val encryptAndUploadAttachmentUseCase: EncryptAndUploadAttachmentUseCase,
        private val resolveVideoCircleMetaUseCase: ResolveVideoCircleMetaUseCase,
        private val encodeVideoUriToHexUseCase: EncodeVideoUriToHexUseCase
) : ViewModel() {

    companion object {
        private const val TAG = "ChatViewModel"
        private const val PAGINATION_PAGE_SIZE = 30
        private const val INITIAL_PAGE_SIZE_LOW = 12
        private const val INITIAL_PAGE_SIZE_AVERAGE = 18
        private const val INITIAL_PAGE_SIZE_HIGH = 30
        private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
        private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
        private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
        private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
        private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
        // Keep sort stable for equal timestamps: tie order comes from existing list/insertion order.
        private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
        private val chatMessageDescComparator =
                compareByDescending<ChatMessage> { it.timestamp.time }
        private val forwardUuidRegex =
                Regex(
                        "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
                )

        private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
                messages.sortedWith(chatMessageAscComparator)

        // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
        // Ключ включает account для изоляции данных между аккаунтами
        private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()

        /** Формирует ключ кэша с привязкой к аккаунту */
        private fun cacheKey(account: String, dialogKey: String) = "$account|$dialogKey"

        /**
         * 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE
         * сообщений для предотвращения OOM
         */
        private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
            val orderedMessages = sortMessagesAsc(messages)
            val limitedMessages =
                    if (orderedMessages.size > MAX_CACHE_SIZE) {
                        // Оставляем только последние сообщения в детерминированном порядке.
                        orderedMessages.takeLast(MAX_CACHE_SIZE)
                    } else {
                        orderedMessages
                    }
            dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
        }

        /** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */
        fun clearDialogCache(dialogKey: String) {
            // Очищаем для всех аккаунтов — при удалении диалога dialogKey не привязан к аккаунту
            val keysToRemove = dialogMessagesCache.keys.filter { it.endsWith("|$dialogKey") }
            keysToRemove.forEach { dialogMessagesCache.remove(it) }
        }

        /** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */
        fun clearCacheForOpponent(opponentKey: String) {
            val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) }
            keysToRemove.forEach { dialogMessagesCache.remove(it) }
        }

        /** 🗑️ Полная очистка кэша — вызывается при переключении аккаунта */
        fun clearAllDialogCache() {
            dialogMessagesCache.clear()
        }
    }

    // Database
    private val database = RosettaDatabase.getDatabase(app)
    private val dialogDao = database.dialogDao()
    private val messageDao = database.messageDao()
    private val searchIndexDao = database.messageSearchIndexDao()
    private val groupDao = database.groupDao()
    private val pinnedMessageDao = database.pinnedMessageDao()
    // 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
    private val decryptionCache = ConcurrentHashMap<String, String>()
    private val groupKeyCache = ConcurrentHashMap<String, String>()
    private val groupSenderNameCache = ConcurrentHashMap<String, String>()
    private val groupSenderResolveRequested = ConcurrentHashMap.newKeySet<String>()
    @Volatile private var isCleared = false

    // Информация о собеседнике
    private var opponentTitle: String = ""
    private var opponentUsername: String = ""
    private var opponentVerified: Int = 0

    // Текущий диалог
    private var opponentKey: String? = null
    var myPublicKey: String? = null // 🔥 Публичный доступ для OtherProfileScreen
        private set
    private var myPrivateKey: String? = null

    // UI State
    private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
    val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
    @Volatile private var normalizedMessagesDescCache: List<ChatMessage> = emptyList()

    /**
     * Pre-computed messages with date headers — runs dedup + sort on Dispatchers.Default. Replaces
     * the old `remember(messages) { ... }` block in ChatDetailScreen that ran O(n log n) sorting +
     * SimpleDateFormat allocation in a loop on the main thread.
     */
    private val _dateFmt =
            java.text.SimpleDateFormat(
                    "yyyyMMdd",
                    java.util.Locale.US
            ) // single instance, thread-safe via Default dispatcher

    data class ChatOpenMetricsSnapshot(
            val chat: String = "",
            val messages: Int = 0,
            val messagesWithDates: Int = 0,
            val isLoading: Boolean = false,
            val openDialogMs: Long? = null,
            val firstMessagesMs: Long? = null,
            val messagesWithDatesReadyMs: Long? = null,
            val firstListLayoutMs: Long? = null,
            val loadingFinishedMs: Long? = null,
            val eventLog: List<String> = emptyList()
    )

    private val _chatOpenMetrics = MutableStateFlow(ChatOpenMetricsSnapshot())
    val chatOpenMetrics: StateFlow<ChatOpenMetricsSnapshot> = _chatOpenMetrics.asStateFlow()

    @Volatile private var chatOpenTraceStartedAtMs: Long = 0L

    @OptIn(FlowPreview::class)
    val messagesWithDates: StateFlow<List<Pair<ChatMessage, Boolean>>> =
            _messages
                    .debounce(16) // coalesce rapid updates (1 frame)
                    .mapLatest { rawMessages ->
                        withContext(Dispatchers.Default) {
                            val normalized =
                                    normalizeMessagesDescendingIncremental(
                                            previous = normalizedMessagesDescCache,
                                            incoming = rawMessages
                                    )
                            normalizedMessagesDescCache = normalized
                            val withDates = buildMessagesWithDateHeaders(normalized)
                            updateChatOpenMetricsCounts(
                                    messagesCount = rawMessages.size,
                                    messagesWithDatesCount = withDates.size
                            )
                            if (rawMessages.isNotEmpty()) {
                                markFirstMessagesLoadedIfNeeded(rawMessages.size)
                            }
                            if (withDates.isNotEmpty()) {
                                markMessagesWithDatesReadyIfNeeded(withDates.size)
                            }
                            withDates
                        }
                    }
                    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    private val _isLoadingMore = MutableStateFlow(false)
    val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()

    private val _groupRequiresRejoin = MutableStateFlow(false)
    val groupRequiresRejoin: StateFlow<Boolean> = _groupRequiresRejoin.asStateFlow()

    private val _opponentTyping = MutableStateFlow(false)
    val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
    private val _typingDisplayName = MutableStateFlow("")
    val typingDisplayName: StateFlow<String> = _typingDisplayName.asStateFlow()
    private val _typingDisplayPublicKey = MutableStateFlow("")
    val typingDisplayPublicKey: StateFlow<String> = _typingDisplayPublicKey.asStateFlow()
    private var typingSnapshotJob: kotlinx.coroutines.Job? = null
    private var typingNameResolveJob: kotlinx.coroutines.Job? = null
    @Volatile private var typingSenderPublicKey: String? = null
    @Volatile private var typingUsersCount: Int = 1

    // 🟢 Онлайн статус собеседника
    private val _opponentOnline = MutableStateFlow(false)
    val opponentOnline: StateFlow<Boolean> = _opponentOnline.asStateFlow()

    private val _opponentLastSeen = MutableStateFlow(0L)
    val opponentLastSeen: StateFlow<Long> = _opponentLastSeen.asStateFlow()
    private var opponentOnlineStatusJob: Job? = null

    // Input state
    private val _inputText = MutableStateFlow("")
    val inputText: StateFlow<String> = _inputText.asStateFlow()

    // 🔥 Reply/Forward state (как в React Native)
    data class ReplyMessage(
            val messageId: String,
            val text: String,
            val timestamp: Long,
            val isOutgoing: Boolean,
            val publicKey: String = "", // publicKey отправителя цитируемого сообщения
            val senderName: String = "", // Имя отправителя для атрибуции forward
            val attachments: List<MessageAttachment> = emptyList(), // Для показа превью
            val chachaKeyPlainHex: String = "" // Hex plainKeyAndNonce для forward
    )
    private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
    val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()

    private val _isForwardMode = MutableStateFlow(false)

    // Animated deletion: IDs of messages currently animating out
    private val _pendingDeleteIds = MutableStateFlow<Set<String>>(emptySet())
    val pendingDeleteIds: StateFlow<Set<String>> = _pendingDeleteIds.asStateFlow()
    val isForwardMode: StateFlow<Boolean> = _isForwardMode.asStateFlow()

    // 📌 Pinned messages state
    private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList())
    val pinnedMessages: StateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>> = _pinnedMessages.asStateFlow()
    private val _pinnedMessagePreviews = MutableStateFlow<Map<String, String>>(emptyMap())
    val pinnedMessagePreviews: StateFlow<Map<String, String>> = _pinnedMessagePreviews.asStateFlow()

    private val _currentPinnedIndex = MutableStateFlow(0)
    val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow()

    private var pinnedCollectionJob: Job? = null

    // Пагинация
    private var currentOffset = 0
    private var hasMoreMessages = true
    private var isLoadingMessages = false
    private val initialPageSize: Int by lazy {
        when (DevicePerformanceClass.get(app)) {
            PerformanceClass.LOW -> INITIAL_PAGE_SIZE_LOW
            PerformanceClass.AVERAGE -> INITIAL_PAGE_SIZE_AVERAGE
            PerformanceClass.HIGH -> INITIAL_PAGE_SIZE_HIGH
        }
    }

    // Защита от двойной отправки
    private var isSending = false

    // Job для отмены загрузки при смене диалога
    private var loadingJob: Job? = null
    private var draftSaveJob: Job? = null
    private val outgoingImageUploadJobs = ConcurrentHashMap<String, Job>()

    // 🔥 Throttling для typing индикатора
    private var lastTypingSentTime = 0L
    private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек

    // Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного
    private var lastReadMessageTimestamp = 0L

    private fun chatTraceElapsedMs(): Long =
            if (chatOpenTraceStartedAtMs <= 0L) 0L
            else (SystemClock.elapsedRealtime() - chatOpenTraceStartedAtMs).coerceAtLeast(0L)

    private fun shortTraceKey(value: String, maxLength: Int = 14): String =
            if (value.length <= maxLength) value else value.take(maxLength)

    private fun shortSendKey(value: String?): String {
        val normalized = value?.trim().orEmpty()
        if (normalized.isEmpty()) return "<empty>"
        return if (normalized.length <= 12) normalized else "${normalized.take(12)}…"
    }

    @Synchronized
    private fun appendChatOpenTraceEvent(event: String, details: String = "") {
        val traceLine =
                buildString {
                    append("[+")
                    append(chatTraceElapsedMs())
                    append("ms] ")
                    append(event)
                    if (details.isNotBlank()) {
                        append(" ")
                        append(details)
                    }
                }
        _chatOpenMetrics.update { current ->
            val trimmedLog =
                    if (current.eventLog.size >= 120) current.eventLog.takeLast(119)
                    else current.eventLog
            current.copy(eventLog = trimmedLog + traceLine)
        }
    }

    @Synchronized
    private fun resetChatOpenTrace(dialogKey: String, reason: String) {
        chatOpenTraceStartedAtMs = SystemClock.elapsedRealtime()
        _chatOpenMetrics.value =
                ChatOpenMetricsSnapshot(
                        chat = dialogKey,
                        messages = 0,
                        messagesWithDates = 0,
                        isLoading = false,
                        eventLog = emptyList()
                )
        appendChatOpenTraceEvent("trace_reset:", reason)
        appendChatOpenTraceEvent("dialog_open_started", "user=${shortTraceKey(dialogKey)}")
    }

    fun addChatOpenTraceEvent(event: String, details: String = "") {
        appendChatOpenTraceEvent(event, details)
    }

    private fun updateChatOpenMetricsCounts(messagesCount: Int, messagesWithDatesCount: Int) {
        _chatOpenMetrics.update { current ->
            current.copy(
                    messages = messagesCount,
                    messagesWithDates = messagesWithDatesCount,
                    isLoading = _isLoading.value
            )
        }
    }

    @Synchronized
    private fun markFirstMessagesLoadedIfNeeded(messagesCount: Int) {
        val current = _chatOpenMetrics.value
        if (current.firstMessagesMs == null) {
            val elapsed = chatTraceElapsedMs()
            _chatOpenMetrics.update {
                it.copy(
                        messages = messagesCount,
                        firstMessagesMs = elapsed
                )
            }
            appendChatOpenTraceEvent("first_messages_loaded", "count=$messagesCount")
        } else if (current.messages != messagesCount) {
            _chatOpenMetrics.update { it.copy(messages = messagesCount) }
        }
    }

    @Synchronized
    private fun markMessagesWithDatesReadyIfNeeded(messagesWithDatesCount: Int) {
        val current = _chatOpenMetrics.value
        if (current.messagesWithDatesReadyMs == null) {
            val elapsed = chatTraceElapsedMs()
            _chatOpenMetrics.update {
                it.copy(
                        messagesWithDates = messagesWithDatesCount,
                        messagesWithDatesReadyMs = elapsed
                )
            }
            appendChatOpenTraceEvent(
                    "messages_with_dates_ready",
                    "count=$messagesWithDatesCount"
            )
        } else if (current.messagesWithDates != messagesWithDatesCount) {
            _chatOpenMetrics.update { it.copy(messagesWithDates = messagesWithDatesCount) }
        }
    }

    @Synchronized
    private fun markOpenDialogFinishedIfNeeded() {
        val current = _chatOpenMetrics.value
        if (current.openDialogMs == null) {
            _chatOpenMetrics.update { it.copy(openDialogMs = chatTraceElapsedMs()) }
        }
    }

    @Synchronized
    private fun markLoadingFinishedIfNeeded() {
        val current = _chatOpenMetrics.value
        if (current.loadingFinishedMs == null) {
            val elapsed = chatTraceElapsedMs()
            _chatOpenMetrics.update { it.copy(loadingFinishedMs = elapsed, isLoading = false) }
            appendChatOpenTraceEvent("loading_finished")
        }
    }

    @Synchronized
    fun markFirstListLayoutReady() {
        val current = _chatOpenMetrics.value
        if (current.firstListLayoutMs == null && current.messagesWithDates > 0) {
            val elapsed = chatTraceElapsedMs()
            _chatOpenMetrics.update { it.copy(firstListLayoutMs = elapsed) }
            appendChatOpenTraceEvent("first_list_layout_ready")
        }
    }

    private fun setLoading(value: Boolean) {
        _isLoading.value = value
        _chatOpenMetrics.update { it.copy(isLoading = value) }
        if (!value) {
            markLoadingFinishedIfNeeded()
        }
    }

    private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
            messages.sortedWith(chatMessageAscComparator)

    private fun isSortedDescending(messages: List<ChatMessage>): Boolean {
        if (messages.size < 2) return true
        for (i in 0 until messages.lastIndex) {
            if (chatMessageDescComparator.compare(messages[i], messages[i + 1]) > 0) {
                return false
            }
        }
        return true
    }

    private fun insertIntoSortedDescending(
            existing: List<ChatMessage>,
            message: ChatMessage
    ): List<ChatMessage> {
        if (existing.isEmpty()) return listOf(message)
        val result = ArrayList<ChatMessage>(existing.size + 1)
        var inserted = false
        existing.forEach { current ->
            if (!inserted && chatMessageDescComparator.compare(message, current) <= 0) {
                result.add(message)
                inserted = true
            }
            result.add(current)
        }
        if (!inserted) result.add(message)
        return result
    }

    private fun normalizeMessagesDescendingIncremental(
            previous: List<ChatMessage>,
            incoming: List<ChatMessage>
    ): List<ChatMessage> {
        if (incoming.isEmpty()) return emptyList()

        val dedupedById = LinkedHashMap<String, ChatMessage>(incoming.size)
        incoming.forEach { message -> dedupedById[message.id] = message }

        if (previous.isNotEmpty() && dedupedById.size == previous.size) {
            var unchanged = true
            previous.forEach { message ->
                if (dedupedById[message.id] != message) {
                    unchanged = false
                    return@forEach
                }
            }
            if (unchanged && isSortedDescending(previous)) {
                return previous
            }
        }

        if (previous.isNotEmpty() && dedupedById.size == previous.size + 1) {
            val previousIds = HashSet<String>(previous.size)
            previous.forEach { previousIds.add(it.id) }

            val addedIds = dedupedById.keys.filter { it !in previousIds }
            if (addedIds.size == 1) {
                var previousUnchanged = true
                previous.forEach { message ->
                    if (dedupedById[message.id] != message) {
                        previousUnchanged = false
                        return@forEach
                    }
                }
                if (previousUnchanged) {
                    val addedMessage = dedupedById.getValue(addedIds.first())
                    return insertIntoSortedDescending(previous, addedMessage)
                }
            }
        }

        val normalized = ArrayList<ChatMessage>(dedupedById.values)
        if (!isSortedDescending(normalized)) {
            normalized.sortWith(chatMessageDescComparator)
        }
        return normalized
    }

    private fun buildMessagesWithDateHeaders(
            sortedMessagesDesc: List<ChatMessage>
    ): List<Pair<ChatMessage, Boolean>> {
        val result = ArrayList<Pair<ChatMessage, Boolean>>(sortedMessagesDesc.size)
        for (i in sortedMessagesDesc.indices) {
            val msg = sortedMessagesDesc[i]
            val dateStr = _dateFmt.format(msg.timestamp)
            val nextMsg = sortedMessagesDesc.getOrNull(i + 1)
            val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
            val showDate = nextDateStr == null || nextDateStr != dateStr
            result.add(msg to showDate)
        }
        return result
    }

    private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? =
            messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator)

    // 🔥 Флаг что диалог АКТИВЕН (пользователь внутри чата, а не на главной)
    // Как currentDialogPublicKeyView в архиве
    private var isDialogActive = false

    // 🟢 Флаг что уже подписаны на онлайн статус собеседника
    private var subscribedToOnlineStatus = false

    init {
        setupTypingStateObserver()
        setupNewMessageListener()
        setupDeliveryStatusListener()
    }

    internal val messagesCoordinator: MessagesCoordinator by lazy { MessagesCoordinator(this) }
    internal val forwardCoordinator: ForwardCoordinator by lazy { ForwardCoordinator(this, sendForwardUseCase) }
    internal val attachmentsCoordinator: AttachmentsCoordinator by lazy { AttachmentsCoordinator(this) }
    internal val attachmentsFeatureCoordinator: AttachmentsFeatureCoordinator by lazy {
        AttachmentsFeatureCoordinator(
            chatViewModel = this,
            resolveVideoCircleMetaUseCase = resolveVideoCircleMetaUseCase,
            encodeVideoUriToHexUseCase = encodeVideoUriToHexUseCase
        )
    }
    val messagesViewModel: MessagesViewModel by lazy { MessagesViewModel(this) }
    val voiceRecordingViewModel: VoiceRecordingViewModel by lazy { VoiceRecordingViewModel(this) }
    val attachmentsViewModel: AttachmentsViewModel by lazy { AttachmentsViewModel(this) }
    val typingViewModel: TypingViewModel by lazy { TypingViewModel(this) }

    internal fun resolveOutgoingSendContext(): OutgoingSendContext? {
        val recipient = opponentKey ?: return null
        val sender = myPublicKey ?: return null
        val privateKey = myPrivateKey ?: return null
        return OutgoingSendContext(
                recipient = recipient,
                sender = sender,
                privateKey = privateKey
        )
    }

    @Synchronized
    internal fun tryAcquireSendSlot(): Boolean {
        if (isSending) return false
        isSending = true
        return true
    }

    @Synchronized
    internal fun releaseSendSlot() {
        isSending = false
    }

    internal fun isSendSlotBusy(): Boolean = isSending

    internal fun hasRuntimeKeysForSend(): Boolean {
        return !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank()
    }

    internal fun resolveRepositoryRuntimeKeys(): Pair<String, String>? {
        val repositoryPublicKey = messageRepository.getCurrentAccountKey()?.trim().orEmpty()
        val repositoryPrivateKey = messageRepository.getCurrentPrivateKey()?.trim().orEmpty()
        if (repositoryPublicKey.isBlank() || repositoryPrivateKey.isBlank()) return null
        return repositoryPublicKey to repositoryPrivateKey
    }

    internal fun currentRecipientForSend(): String? = opponentKey?.trim()?.takeIf { it.isNotEmpty() }

    internal fun currentSenderPublicKeyForSend(): String? =
            myPublicKey?.trim()?.takeIf { it.isNotEmpty() }

    internal fun currentSenderPrivateKeyForSend(): String? =
            myPrivateKey?.trim()?.takeIf { it.isNotEmpty() }

    internal fun isCurrentDialogTarget(recipientPublicKey: String): Boolean {
        return recipientPublicKey == opponentKey
    }

    internal fun addOutgoingMessageOptimistic(message: ChatMessage) {
        addMessageSafely(message)
    }

    internal fun clearInputText() {
        _inputText.value = ""
    }

    internal fun appContext(): Application = app

    internal fun launchOnIo(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(Dispatchers.IO, block = block)
    }

    internal fun launchInViewModel(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(block = block)
    }

    internal fun launchBackgroundUpload(block: suspend CoroutineScope.() -> Unit): Job {
        return backgroundUploadScope.launch(block = block)
    }

    internal fun registerOutgoingImageUploadJob(messageId: String, job: Job) {
        outgoingImageUploadJobs[messageId] = job
        job.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) }
    }

    internal fun buildVoicePayload(
            voiceHex: String,
            durationSec: Int,
            waves: List<Float>
    ) =
            sendVoiceMessageUseCase(
                    SendVoiceMessageCommand(
                            voiceHex = voiceHex,
                            durationSec = durationSec,
                            waves = waves
                    )
            )

    internal fun sendMediaMessage(command: SendMediaMessageCommand) {
        sendMediaMessageUseCase(command)
    }

    internal suspend fun encryptAndUploadAttachment(command: EncryptAndUploadAttachmentCommand) =
            encryptAndUploadAttachmentUseCase(command)

    internal fun createFileAttachment(command: CreateFileAttachmentCommand) =
            createFileAttachmentUseCase(command)

    internal fun createAvatarAttachment(command: CreateAvatarAttachmentCommand) =
            createAvatarAttachmentUseCase(command)

    internal fun createVideoCircleAttachment(command: CreateVideoCircleAttachmentCommand) =
            createVideoCircleAttachmentUseCase(command)

    internal suspend fun saveOutgoingMessage(
            messageId: String,
            text: String,
            encryptedContent: String,
            encryptedKey: String,
            timestamp: Long,
            delivered: Int,
            attachmentsJson: String,
            accountPublicKey: String,
            accountPrivateKey: String,
            opponentPublicKey: String
    ) {
        saveMessageToDatabase(
                messageId = messageId,
                text = text,
                encryptedContent = encryptedContent,
                encryptedKey = encryptedKey,
                timestamp = timestamp,
                isFromMe = true,
                delivered = delivered,
                attachmentsJson = attachmentsJson,
                accountPublicKey = accountPublicKey,
                accountPrivateKey = accountPrivateKey,
                opponentPublicKey = opponentPublicKey
        )
    }

    internal suspend fun saveOutgoingDialog(
            lastMessage: String,
            timestamp: Long,
            accountPublicKey: String,
            accountPrivateKey: String,
            opponentPublicKey: String
    ) {
        saveDialog(
                lastMessage = lastMessage,
                timestamp = timestamp,
                accountPublicKey = accountPublicKey,
                accountPrivateKey = accountPrivateKey,
                opponentPublicKey = opponentPublicKey
        )
    }

    internal fun updateMessageStatusUi(messageId: String, status: MessageStatus) {
        updateMessageStatus(messageId, status)
    }

    internal fun cacheDecryptedText(messageId: String, text: String) {
        decryptionCache[messageId] = text
    }

    internal suspend fun updateMessageStatusDb(messageId: String, delivered: Int) {
        updateMessageStatusInDb(messageId, delivered)
    }

    internal suspend fun updateMessageStatusAndAttachmentsDb(
            messageId: String,
            delivered: Int,
            attachmentsJson: String
    ) {
        updateMessageStatusAndAttachmentsInDb(
                messageId = messageId,
                delivered = delivered,
                attachmentsJson = attachmentsJson
        )
    }

    internal suspend fun refreshDialogFromMessagesForForward(
            accountPublicKey: String,
            opponentPublicKey: String
    ) {
        val account = accountPublicKey.trim().takeIf { it.isNotEmpty() } ?: return
        val opponent = opponentPublicKey.trim().takeIf { it.isNotEmpty() } ?: return

        runCatching {
            if (opponent == account) {
                dialogDao.updateSavedMessagesDialogFromMessages(account)
            } else {
                dialogDao.updateDialogFromMessages(account, opponent)
            }
        }
    }

    internal fun sendTextMessage(command: SendTextMessageCommand) = sendTextMessageUseCase(command)

    internal fun replyFallbackName(): String {
        return opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
    }

    internal fun resolveReplyPreviewTextForSend(
            text: String,
            attachments: List<MessageAttachment>
    ): String {
        return resolveReplyPreviewText(text, attachments)
    }

    internal fun buildStoredGroupEncryptedKey(groupKey: String, privateKey: String): String {
        return buildStoredGroupKey(groupKey, privateKey)
    }

    internal fun resolveTypingSendContext(): TypingSendContext? {
        val opponent = opponentKey ?: return null
        val sender = myPublicKey ?: return null
        val privateKey = myPrivateKey ?: return null
        return TypingSendContext(
                opponent = opponent,
                sender = sender,
                privateKey = privateKey,
                isGroupDialog = isGroupDialogKey(opponent),
                isOpponentOnline = _opponentOnline.value
        )
    }

    internal fun decideTypingSend(command: SendTypingIndicatorCommand) =
            sendTypingIndicatorUseCase(command)

    internal fun lastTypingSentTimeMs(): Long = lastTypingSentTime

    internal fun setLastTypingSentTimeMs(value: Long) {
        lastTypingSentTime = value
    }

    internal fun typingThrottleMs(): Long = TYPING_THROTTLE_MS

    internal fun sendTypingPacket(privateKey: String, sender: String, opponent: String) {
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
                val packet =
                        PacketTyping().apply {
                            this.privateKey = privateKeyHash
                            fromPublicKey = sender
                            toPublicKey = opponent
                        }
                protocolGateway.send(packet)
            } catch (_: Exception) {
            }
        }
    }

    internal fun logPhotoEvent(messageId: String, details: String) {
        logPhotoPipeline(messageId, details)
    }

    internal fun addProtocolLog(message: String) {
        protocolGateway.addLog(message)
    }

    internal fun logPhotoErrorEvent(messageId: String, stage: String, error: Throwable) {
        logPhotoPipelineError(messageId, stage, error)
    }

    internal fun shortPhotoLogId(value: String, maxLength: Int = 12): String {
        return shortPhotoId(value, maxLength)
    }

    internal fun isViewModelCleared(): Boolean = isCleared

    internal fun resolveFileSizeForUri(videoUri: android.net.Uri): Long {
        return runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(app, videoUri) }
                .getOrDefault(0L)
    }

    internal fun maxMediaBytes(): Long {
        return com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L
    }

    internal fun trySendTextMessage(allowPendingRecovery: Boolean) {
        messagesCoordinator.trySendMessage(allowPendingRecovery = allowPendingRecovery)
    }

    // 🔥 Debounce для защиты от спама входящих сообщений
    private var pendingMessageUpdates = mutableSetOf<String>()
    private var messageUpdateJob: kotlinx.coroutines.Job? = null
    private val MESSAGE_BATCH_DELAY_MS = 100L // Собираем сообщения за 100ms и обрабатываем пачкой

    /**
     * 🔔 Подписка на события новых сообщений от MessageRepository Обновляет UI в реальном времени
     * когда приходит новое сообщение 🚀 ОПТИМИЗАЦИЯ: Добавляем сообщение инкрементально, а не
     * перезагружаем весь список 🔥 ЗАЩИТА ОТ СПАМА: Используем debounce + batching чтобы не
     * перегружать UI при спаме
     */
    private fun setupNewMessageListener() {
        viewModelScope.launch {
            messageRepository.newMessageEvents.collect { dialogKey ->
                // Проверяем что это сообщение для текущего диалога
                val account = myPublicKey ?: return@collect
                val opponent = opponentKey ?: return@collect
                val currentDialogKey = getDialogKey(account, opponent)

                if (dialogKey == currentDialogKey) {
                    // 🔥 ЗАЩИТА ОТ СПАМА: Добавляем в очередь и обрабатываем пачкой
                    synchronized(pendingMessageUpdates) {
                        pendingMessageUpdates.add(currentDialogKey)
                    }

                    // Отменяем предыдущий job и создаём новый с задержкой
                    messageUpdateJob?.cancel()
                    messageUpdateJob =
                            viewModelScope.launch {
                                kotlinx.coroutines.delay(MESSAGE_BATCH_DELAY_MS)
                                processPendingMessages(account)
                            }
                }
            }
        }
    }

    /**
     * 🔥 Обработка накопленных событий о новых сообщениях Загружает все новые сообщения одним
     * запросом вместо многих
     */
    private suspend fun processPendingMessages(account: String) {
        val dialogsToProcess: Set<String>
        synchronized(pendingMessageUpdates) {
            dialogsToProcess = pendingMessageUpdates.toSet()
            pendingMessageUpdates.clear()
        }

        for (dialogKey in dialogsToProcess) {
            addLatestMessagesFromDb(account, dialogKey)
        }
    }

    /**
     * 🔥 Загрузка ВСЕХ новых сообщений за раз (batch) Вместо загрузки по одному - загружаем все что
     * появились с момента последнего обновления
     */
    private suspend fun addLatestMessagesFromDb(account: String, dialogKey: String) {
        kotlinx.coroutines.withContext(Dispatchers.IO) {
            try {
                val opponent = opponentKey ?: return@withContext
                val isSavedMessages = (opponent == account)

                // 🔥 Получаем последние N сообщений из БД (увеличено до 50 чтобы поймать весь спам)
                val latestEntities =
                        if (isSavedMessages) {
                            messageDao.getMessagesForSavedDialog(account, limit = 50, offset = 0)
                        } else {
                            messageDao.getMessages(account, dialogKey, limit = 50, offset = 0)
                        }

                if (latestEntities.isEmpty()) return@withContext

                // Фильтруем только новые сообщения
                val existingIds = _messages.value.map { it.id }.toSet()
                val newEntities = latestEntities.filter { it.messageId !in existingIds }

                if (newEntities.isEmpty()) return@withContext

                // Конвертируем все новые сообщения
                val newMessages = newEntities.map { entityToChatMessage(it) }

                // Добавляем все сразу
                kotlinx.coroutines.withContext(Dispatchers.Main.immediate) {
                    val currentList = _messages.value
                    val newList = sortMessagesAscending((currentList + newMessages).distinctBy { it.id })
                    _messages.value = newList
                }

                // Обновляем кэш
                val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
                if (cachedMessages != null) {
                    updateCacheWithLimit(account, dialogKey, cachedMessages + newMessages)
                } else {
                    updateCacheWithLimit(account, dialogKey, _messages.value)
                }

                currentOffset += newMessages.size

                // Read receipts
                if (isDialogActive) {
                    messageDao.markDialogAsRead(account, dialogKey)
                    val hasIncoming = newMessages.any { !it.isOutgoing }
                    if (hasIncoming && !isSavedMessages) {
                        sendReadReceiptToOpponent()
                    }
                }

                // Обновляем диалог
                if (isSavedMessages) {
                    dialogDao.updateSavedMessagesDialogFromMessages(account)
                } else {
                    dialogDao.updateDialogFromMessages(account, opponent)
                }
            } catch (e: Exception) {
                // Ignore errors during batch processing
            }
        }
    }

    /**
     * 🔔 Подписка на события изменения статуса доставки Обновляет UI когда сообщение доставлено или
     * прочитано
     */
    private fun setupDeliveryStatusListener() {
        viewModelScope.launch {
            messageRepository.deliveryStatusEvents.collect { update ->
                val account = myPublicKey ?: return@collect
                val opponent = opponentKey ?: return@collect
                val currentDialogKey = getDialogKey(account, opponent)

                if (update.dialogKey == currentDialogKey) {
                    when (update.status) {
                        DeliveryStatus.DELIVERED -> {
                            updateMessageStatus(update.messageId, MessageStatus.DELIVERED)
                        }
                        DeliveryStatus.ERROR -> {
                            updateMessageStatus(update.messageId, MessageStatus.ERROR)
                        }
                        DeliveryStatus.READ -> {
                            markAllOutgoingAsRead()
                        }
                        else -> {}
                    }
                }
            }
        }
    }

    /** Помечает все исходящие сообщения как прочитанные */
    private fun markAllOutgoingAsRead() {
        _messages.value =
                _messages.value.map { msg ->
                    if (!msg.isOutgoing) return@map msg

                    val nextStatus =
                        when (msg.status) {
                            // Read event can promote only messages that were already delivery-confirmed.
                            MessageStatus.DELIVERED,
                            MessageStatus.READ -> MessageStatus.READ
                            MessageStatus.SENT,
                            MessageStatus.SENDING,
                            MessageStatus.ERROR -> msg.status
                        }

                    if (nextStatus != msg.status) msg.copy(status = nextStatus) else msg
                }
        updateCacheFromCurrentMessages()
    }

    private fun setupTypingStateObserver() {
        typingSnapshotJob?.cancel()
        typingSnapshotJob =
                viewModelScope.launch {
                    protocolGateway.typingUsersByDialogSnapshot.collect { snapshot ->
                        val currentDialog = opponentKey?.trim().orEmpty()
                        val currentAccount = myPublicKey?.trim().orEmpty()

                        if (currentDialog.isBlank()) {
                            clearTypingIndicatorState()
                            return@collect
                        }

                        val activeTypingUsers =
                                resolveTypingUsersForCurrentDialog(snapshot, currentDialog)
                                        .asSequence()
                                        .map { it.trim() }
                                        .filter { it.isNotBlank() }
                                        .filterNot {
                                            currentAccount.isNotBlank() &&
                                                    it.equals(currentAccount, ignoreCase = true)
                                        }
                                        .distinct()
                                        .sorted()
                                        .toList()

                        if (activeTypingUsers.isEmpty()) {
                            clearTypingIndicatorState()
                            return@collect
                        }

                        if (isGroupDialogKey(currentDialog)) {
                            showTypingIndicator(
                                    senderPublicKey = activeTypingUsers.first(),
                                    typingUsersCount = activeTypingUsers.size
                            )
                        } else {
                            showTypingIndicator()
                        }
                    }
                }
    }

    private fun resolveTypingUsersForCurrentDialog(
            snapshot: Map<String, Set<String>>,
            dialogPublicKey: String
    ): Set<String> {
        val normalizedDialog = dialogPublicKey.trim()
        if (normalizedDialog.isBlank()) return emptySet()

        return if (isGroupDialogKey(normalizedDialog)) {
            val groupId = normalizeGroupId(normalizedDialog)
            snapshot.entries.firstOrNull { (key, _) ->
                isGroupDialogKey(key) &&
                        normalizeGroupId(key).equals(groupId, ignoreCase = true)
            }?.value ?: emptySet()
        } else {
            snapshot.entries.firstOrNull { (key, _) ->
                !isGroupDialogKey(key) && key.equals(normalizedDialog, ignoreCase = true)
            }?.value ?: emptySet()
        }
    }

    private fun observeOpponentOnlineStatus() {
        opponentOnlineStatusJob?.cancel()

        val account = myPublicKey?.trim().orEmpty()
        val opponent = opponentKey?.trim().orEmpty()
        if (
                account.isBlank() ||
                        opponent.isBlank() ||
                        account.equals(opponent, ignoreCase = true) ||
                        isGroupDialogKey(opponent)
        ) {
            _opponentOnline.value = false
            _opponentLastSeen.value = 0L
            return
        }

        opponentOnlineStatusJob =
                viewModelScope.launch {
                    messageRepository.observeUserOnlineStatus(opponent).collect { (isOnline, lastSeen) ->
                        val currentAccount = myPublicKey?.trim().orEmpty()
                        val currentDialog = opponentKey?.trim().orEmpty()
                        if (
                                !currentAccount.equals(account, ignoreCase = true) ||
                                        !currentDialog.equals(opponent, ignoreCase = true)
                        ) {
                            return@collect
                        }
                        _opponentOnline.value = isOnline
                        _opponentLastSeen.value = lastSeen
                    }
                }
    }

    // ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в
    // MessageRepository
    // Это предотвращает дублирование сообщений

    /**
     * 🔥 Безопасное добавление сообщения - предотвращает дубликаты Возвращает true если сообщение
     * было добавлено
     */
    private fun addMessageSafely(message: ChatMessage): Boolean {
        val currentMessages = _messages.value
        val currentIds = currentMessages.map { it.id }.toSet()
        if (message.id in currentIds) {
            return false
        }
        _messages.value = currentMessages + message
        return true
    }

    private fun updateMessageStatus(messageId: String, status: MessageStatus) {
        val currentMessages = _messages.value
        val currentStatus = currentMessages.find { it.id == messageId }?.status

        _messages.value =
                currentMessages.map { msg ->
                    if (msg.id != messageId) return@map msg

                    // Monotonic status: never downgrade
                    // SENDING < SENT < DELIVERED < READ
                    val statusOrder = mapOf(
                        MessageStatus.ERROR to 0,
                        MessageStatus.SENDING to 1,
                        MessageStatus.SENT to 2,
                        MessageStatus.DELIVERED to 3,
                        MessageStatus.READ to 4
                    )
                    val currentOrd = statusOrder[msg.status] ?: 0
                    val newOrd = statusOrder[status] ?: 0

                    // ERROR can always override, otherwise only upgrade
                    val mergedStatus = if (status == MessageStatus.ERROR) {
                        status
                    } else if (newOrd > currentOrd) {
                        status
                    } else {
                        msg.status
                    }

                    if (mergedStatus != msg.status) {
                        msg.copy(status = mergedStatus)
                    } else {
                        msg
                    }
                }

        updateCacheFromCurrentMessages()
    }

    /**
     * Для исходящих media-сообщений (фото/файл/аватар) не держим "часики" после фактической отправки:
     * optimistic WAITING в БД должен отображаться как SENT, если localUri уже очищен.
     */
    private fun shouldTreatWaitingAsSent(entity: MessageEntity): Boolean {
        if (entity.fromMe != 1 || entity.primaryAttachmentType < 0) return false

        val attachments = parseAttachmentsJsonArray(entity.attachments) ?: return false
        if (attachments.length() == 0) return false

        var hasMediaAttachment = false
        for (index in 0 until attachments.length()) {
            val attachment = attachments.optJSONObject(index) ?: continue
            when (parseAttachmentType(attachment)) {
                AttachmentType.IMAGE,
                AttachmentType.FILE,
                AttachmentType.AVATAR,
                AttachmentType.VIDEO_CIRCLE -> {
                    hasMediaAttachment = true
                    if (attachment.optString("localUri", "").isNotBlank()) {
                        // Локальный URI ещё есть => загрузка/подготовка не завершена.
                        return false
                    }
                }
                AttachmentType.UNKNOWN -> continue
                else -> return false
            }
        }

        return hasMediaAttachment
    }

    private fun mapEntityDeliveryStatus(entity: MessageEntity): MessageStatus {
        return when (entity.delivered) {
            DeliveryStatus.WAITING.value ->
                    if (shouldTreatWaitingAsSent(entity)) MessageStatus.SENT
                    else MessageStatus.SENDING
            DeliveryStatus.DELIVERED.value ->
                    if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
            DeliveryStatus.ERROR.value -> MessageStatus.ERROR
            DeliveryStatus.READ.value -> MessageStatus.READ
            else -> MessageStatus.SENT
        }
    }

    private fun shortPhotoId(value: String, limit: Int = 8): String {
        val trimmed = value.trim()
        if (trimmed.isEmpty()) return "unknown"
        return if (trimmed.length <= limit) trimmed else trimmed.take(limit)
    }

    private fun logPhotoPipeline(messageId: String, message: String) {
        protocolGateway.addLog("📸 IMG ${shortPhotoId(messageId)} | $message")
    }

    private fun logPhotoPipelineError(messageId: String, stage: String, throwable: Throwable) {
        val reason = throwable.message ?: "unknown"
        protocolGateway.addLog(
                "❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason"
        )
    }

    internal fun currentMessagesForAttachments(): List<ChatMessage> = _messages.value

    internal fun replaceMessagesForAttachments(
            messages: List<ChatMessage>,
            syncCache: Boolean
    ) {
        _messages.value = messages
        if (syncCache) {
            updateCacheFromCurrentMessages()
        }
    }

    /** 🔥 Обновить кэш из текущих сообщений (для синхронизации после изменений) */
    private fun updateCacheFromCurrentMessages() {
        val account = myPublicKey ?: return
        val opponent = opponentKey ?: return
        val dialogKey = getDialogKey(account, opponent)
        updateCacheWithLimit(account, dialogKey, _messages.value)
    }

    /** Обновить статус сообщения в БД */
    private suspend fun updateMessageStatusInDb(messageId: String, delivered: Int) {
        val account = myPublicKey ?: return
        try {
            messageDao.updateDeliveryStatus(account, messageId, delivered)
        } catch (e: Exception) {}
    }

    /** 🔄 Обновить статус и attachments в БД (для очистки localUri после отправки) */
    private suspend fun updateMessageStatusAndAttachmentsInDb(
            messageId: String,
            delivered: Int,
            attachmentsJson: String
    ) {
        val account = myPublicKey ?: return
        try {
            // Never downgrade delivery status (e.g. DELIVERED→WAITING race)
            val safeDelivered = try {
                val existing = messageDao.findMessageById(account, messageId)
                if (existing != null && existing.delivered > delivered) existing.delivered else delivered
            } catch (_: Exception) { delivered }

            messageDao.updateDeliveryStatusAndAttachments(
                    account,
                    messageId,
                    safeDelivered,
                    attachmentsJson
            )
        } catch (e: Exception) {}
    }

    /** Установить ключи пользователя */
    fun setUserKeys(publicKey: String, privateKey: String) {
        val normalizedPublicKey = publicKey.trim()
        val normalizedPrivateKey = privateKey.trim()

        if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) {
            // Never keep empty keys in memory. Empty sender/private key leads to
            // broken outgoing packets (e.g. from=<empty>) until next app relog.
            if (!myPublicKey.isNullOrBlank() || !myPrivateKey.isNullOrBlank()) {
                dialogMessagesCache.clear()
                decryptionCache.clear()
                groupKeyCache.clear()
                groupSenderNameCache.clear()
                groupSenderResolveRequested.clear()
            }
            myPublicKey = null
            myPrivateKey = null
            return
        }

        if (myPublicKey != normalizedPublicKey) {
            // Clear caches on account switch to prevent cross-account data leakage
            // Безусловная очистка (даже если myPublicKey == null) — свежий ViewModel
            // может получить стейтный кэш от предыдущего аккаунта
            dialogMessagesCache.clear()
            decryptionCache.clear()
            groupKeyCache.clear()
            groupSenderNameCache.clear()
            groupSenderResolveRequested.clear()
        }
        myPublicKey = normalizedPublicKey
        myPrivateKey = normalizedPrivateKey
        if (!opponentKey.isNullOrBlank()) {
            observeOpponentOnlineStatus()
        }
        messagesCoordinator.onSendContextChanged("keys_updated")
    }

    /**
     * Lightweight binding used by input send actions.
     * Unlike openDialog(), this does not reset messages/input and is safe right before send.
     */
    fun ensureSendContext(publicKey: String, title: String = "", username: String = "", verified: Int = 0) {
        val normalizedPublicKey = publicKey.trim()
        if (normalizedPublicKey.isBlank()) return

        val currentDialogKey = opponentKey?.trim().orEmpty()
        if (currentDialogKey.equals(normalizedPublicKey, ignoreCase = true)) {
            messagesCoordinator.onSendContextChanged("send_context_ready")
            return
        }

        opponentKey = normalizedPublicKey
        opponentTitle = title
        opponentUsername = username
        opponentVerified = verified.coerceAtLeast(0)
        if (!isGroupDialogKey(normalizedPublicKey)) {
            groupKeyCache.remove(normalizedPublicKey)
        }
        protocolGateway.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}")
        messagesCoordinator.onSendContextChanged("send_context_bound")
    }

    /** Открыть диалог */
    fun openDialog(publicKey: String, title: String = "", username: String = "", verified: Int = 0) {

        // 🔥 ВСЕГДА перезагружаем данные - не кешируем, т.к. диалог мог быть удалён
        // if (opponentKey == publicKey) {
        //     return
        // }

        // Отменяем предыдущую загрузку
        loadingJob?.cancel()
        resetChatOpenTrace(publicKey, reason = "dialog_enter")
        appendChatOpenTraceEvent("open_dialog_requested")

        opponentKey = publicKey
        opponentTitle = title
        opponentUsername = username
        opponentVerified = verified.coerceAtLeast(0)
        if (!isGroupDialogKey(publicKey)) {
            groupKeyCache.remove(publicKey)
        }
        _groupRequiresRejoin.value = false
        groupSenderNameCache.clear()
        groupSenderResolveRequested.clear()

        // 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния!
        // Это важно для правильного отображения forward сообщений сразу
        val forwardMessages = ForwardManager.consumeForwardMessagesForChat(publicKey)
        val hasForward = forwardMessages.isNotEmpty()
        if (hasForward) {}

        // 🚀 P1: Проверяем кэш ДО сброса состояния — если есть, подставляем мгновенно (без flash)
        val account = myPublicKey
        val cachedMessages =
                if (account != null) {
                    val dialogKey = getDialogKey(account, publicKey)
                    dialogMessagesCache[cacheKey(account, dialogKey)]
                } else null

        // Сбрасываем состояние
        if (cachedMessages != null && cachedMessages.isNotEmpty()) {
            // Мгновенная подстановка из кэша — пользователь не увидит пустой экран
            _messages.value = cachedMessages
            setLoading(false)
        } else {
            // Нет кэша — показываем skeleton вместо пустого Lottie
            setLoading(true)
            _messages.value = emptyList()
        }
        _opponentOnline.value = false
        _opponentLastSeen.value = 0L
        clearTypingIndicatorState()
        currentOffset = 0
        hasMoreMessages = true
        isLoadingMessages = false
        lastReadMessageTimestamp = 0L
        subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
        isDialogActive = true // 🔥 Диалог активен!
        _pinnedMessagePreviews.value = emptyMap()

        // Desktop parity: refresh opponent name/username from server on dialog open,
        // so renamed contacts get their new name displayed immediately.
        messageRepository?.forceRequestUserInfo(publicKey)

        // 📝 Восстанавливаем черновик для этого диалога (draft, как в Telegram)
        val draft = com.rosetta.messenger.data.DraftManager.getDraft(publicKey)
        _inputText.value = draft ?: ""

        // 📨 Применяем Forward сообщения СРАЗУ после сброса
        if (hasForward) {
            // Конвертируем ForwardMessage в ReplyMessage
            _replyMessages.value =
                    forwardMessages.map { fm ->
                        ReplyMessage(
                                messageId = fm.messageId,
                                text = fm.text,
                                timestamp = fm.timestamp,
                                isOutgoing = fm.isOutgoing,
                                publicKey = fm.senderPublicKey,
                                senderName = fm.senderName,
                                attachments = fm.attachments,
                                chachaKeyPlainHex = fm.chachaKeyPlain
                        )
                    }
            _isForwardMode.value = true
        } else {
            // Сбрасываем forward state если нет forward сообщений
            _replyMessages.value = emptyList()
            _isForwardMode.value = false
        }

        // Подписываемся на онлайн статус
        subscribeToOnlineStatus()
        observeOpponentOnlineStatus()

        // 📌 Подписываемся на pinned messages
        pinnedCollectionJob?.cancel()
        pinnedCollectionJob = viewModelScope.launch(Dispatchers.IO) {
            val acc = myPublicKey ?: return@launch
            val dialogKey = getDialogKey(acc, publicKey)
            pinnedMessageDao.getPinnedMessages(acc, dialogKey).collect { pins ->
                _pinnedMessages.value = pins
                // Всегда показываем самый последний пин (index 0, ORDER BY DESC)
                _currentPinnedIndex.value = 0
                refreshPinnedMessagePreviews(acc, pins)
            }
        }

        markOpenDialogFinishedIfNeeded()

        // First-run auth race: if user tapped send before dialog binding finished,
        // flush pending send as soon as dialog context becomes valid.
        messagesCoordinator.onSendContextChanged("open_dialog")

        // � P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer
        loadMessagesFromDatabase(delayMs = 0L)
    }

    /**
     * 🔥 Закрыть диалог (вызывается когда пользователь выходит из чата) Как
     * setCurrentDialogPublicKeyView("") в архиве
     */
    fun closeDialog() {
        isDialogActive = false
        clearTypingIndicatorState()
        opponentOnlineStatusJob?.cancel()
    }

    /**
     * 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE) Предотвращает
     * отметку сообщений как прочитанных когда приложение в фоне
     */
    fun setDialogActive(active: Boolean) {
        isDialogActive = active
    }

    /**
     * 🚀 СУПЕР-оптимизированная загрузка сообщений 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка
     * для анимации + чанковая расшифровка 📁 SAVED MESSAGES: Использует специальные методы для
     * saved messages чтобы избежать дублирования
     */
    private fun loadMessagesFromDatabase(delayMs: Long = 0L) {
        val account = myPublicKey ?: return
        val opponent = opponentKey ?: return
        val dialogKey = getDialogKey(account, opponent)
        appendChatOpenTraceEvent("load_messages_started", "pageSize=$initialPageSize")

        // 📁 Проверяем является ли это Saved Messages
        val isSavedMessages = (opponent == account)

        if (isLoadingMessages) {
            return
        }
        isLoadingMessages = true

        loadingJob =
                viewModelScope.launch(Dispatchers.IO) {
                    try {
                        if (isGroupDialogKey(opponent)) {
                            val hasGroupKey = resolveGroupKeyForDialog(opponent) != null
                            withContext(Dispatchers.Main.immediate) {
                                _groupRequiresRejoin.value = !hasGroupKey
                                if (!hasGroupKey) {
                                    _messages.value = emptyList()
                                    setLoading(false)
                                }
                            }
                            if (!hasGroupKey) {
                                hasMoreMessages = false
                                currentOffset = 0
                                isLoadingMessages = false
                                return@launch
                            }
                        } else {
                            _groupRequiresRejoin.value = false
                        }

                        // 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
                        val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]

                        if (cachedMessages != null && cachedMessages.isNotEmpty()) {
                            withContext(Dispatchers.Main.immediate) {
                                _messages.value = cachedMessages
                                setLoading(false)
                            }

                            // Фоновое обновление из БД (новые сообщения)
                            delay(100) // Небольшая задержка чтобы UI успел отрисоваться
                            refreshMessagesFromDb(
                                    account,
                                    opponent,
                                    dialogKey,
                                    cachedMessages,
                                    isSavedMessages
                            )
                            isLoadingMessages = false
                            return@launch
                        }

                        // 🔥 Нет кэша - проверяем есть ли вообще сообщения в БД
                        // Если диалог пустой - не показываем скелетон!
                        val totalCount =
                                if (isSavedMessages) {
                                    messageDao.getMessageCountForSavedDialog(account)
                                } else {
                                    messageDao.getMessageCount(account, dialogKey)
                                }

                        if (totalCount == 0) {
                            // Пустой диалог - сразу показываем пустое состояние без скелетона
                            withContext(Dispatchers.Main.immediate) {
                                _messages.value = emptyList()
                                setLoading(false)
                            }
                            isLoadingMessages = false
                            return@launch
                        }

                        // 🔥 Есть сообщения - показываем скелетон и загружаем с задержкой для
                        // анимации
                        if (delayMs > 0) {
                            withContext(Dispatchers.Main.immediate) {
                                setLoading(true) // Показываем скелетон
                            }
                            delay(delayMs)
                        }

                        // 🔥 Получаем первую страницу - используем специальный метод для saved
                        // messages
                        val entities =
                                if (isSavedMessages) {
                                    messageDao.getMessagesForSavedDialog(
                                            account,
                                            limit = initialPageSize,
                                            offset = 0
                                    )
                                } else {
                                    messageDao.getMessages(
                                            account,
                                            dialogKey,
                                            limit = initialPageSize,
                                            offset = 0
                                    )
                                }

                        hasMoreMessages = entities.size >= initialPageSize
                        currentOffset = entities.size

                        // � P2.2: ПАРАЛЛЕЛЬНАЯ расшифровка с Semaphore
                        // Запускаем до DECRYPT_PARALLELISM одновременных расшифровок
                        val reversedEntities = entities.asReversed()
                        val semaphore = Semaphore(DECRYPT_PARALLELISM)
                        val messages = coroutineScope {
                            reversedEntities
                                    .map { entity ->
                                        async {
                                            semaphore.withPermit { entityToChatMessage(entity) }
                                        }
                                    }
                                    .awaitAll()
                        }

                        // 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
                        updateCacheWithLimit(account, dialogKey, messages.toList())

                        // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
                        // НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
                        withContext(Dispatchers.Main.immediate) {
                            val dbIds = messages.map { it.id }.toSet()
                            val currentMsgs = _messages.value
                            val optimisticMessages =
                                    currentMsgs.filter { msg ->
                                        msg.status == MessageStatus.SENDING && msg.id !in dbIds
                                    }
                            val newList = messages + optimisticMessages

                            // 🔍 Финальная дедупликация по ID (на всякий случай)
                            val deduplicatedList =
                                    sortMessagesAscending(newList.distinctBy { it.id })

                            if (deduplicatedList.size != newList.size) {}

                            _messages.value = deduplicatedList
                            setLoading(false)
                        }

                        // 🔥 Фоновые операции БЕЗ блокировки UI
                        launch(Dispatchers.IO) {
                            // 👁️ Отмечаем как прочитанные ТОЛЬКО если диалог активен
                            if (isDialogActive) {
                                messageDao.markDialogAsRead(account, dialogKey)
                            }
                            // 🔥 Пересчитываем счетчики из messages - используем специальный метод
                            // для saved messages
                            if (isSavedMessages) {
                                dialogDao.updateSavedMessagesDialogFromMessages(account)
                            } else {
                                dialogDao.updateDialogFromMessages(account, opponent)
                            }

                            // Отправляем read receipt собеседнику (НЕ для saved messages!)
                            if (!isSavedMessages && messages.isNotEmpty()) {
                                val lastIncoming = latestIncomingMessage(messages)
                                if (lastIncoming != null &&
                                                lastIncoming.timestamp.time >
                                                        lastReadMessageTimestamp
                                ) {
                                    sendReadReceiptToOpponent()
                                }
                            }
                        }

                        isLoadingMessages = false
                    } catch (e: Exception) {
                        withContext(Dispatchers.Main.immediate) { setLoading(false) }
                        isLoadingMessages = false
                    }
                }
    }

    /**
     * 🔥 Фоновое обновление сообщений из БД (проверка новых) Вызывается когда кэш уже отображён, но
     * нужно проверить есть ли новые сообщения 🔥 ВАЖНО: НЕ заменяем все сообщения - только
     * добавляем новые, сохраняя существующие! 📁 SAVED MESSAGES: Использует специальные методы для
     * saved messages
     */
    private suspend fun refreshMessagesFromDb(
            account: String,
            opponent: String,
            dialogKey: String,
            cachedMessages: List<ChatMessage>,
            isSavedMessages: Boolean
    ) {
        try {
            val entities =
                    if (isSavedMessages) {
                        messageDao.getMessagesForSavedDialog(account, limit = initialPageSize, offset = 0)
                    } else {
                        messageDao.getMessages(account, dialogKey, limit = initialPageSize, offset = 0)
                    }

            // 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения)
            val currentMessages = _messages.value
            val existingIds = currentMessages.map { it.id }.toSet()
            val entitiesById = entities.associateBy { it.messageId }

            // 🔄 Синхронизируем статусы/время уже существующих сообщений из БД.
            // Это критично для случая WAITING -> ERROR: без этого в открытом диалоге остаются "часики".
            val reconciledMessages =
                    currentMessages.map { message ->
                        val entity = entitiesById[message.id] ?: return@map message

                        val dbStatus = mapEntityDeliveryStatus(entity)

                        var updatedMessage = message
                        if (updatedMessage.status != dbStatus) {
                            updatedMessage = updatedMessage.copy(status = dbStatus)
                        }
                        if (updatedMessage.timestamp.time != entity.timestamp) {
                            updatedMessage = updatedMessage.copy(timestamp = Date(entity.timestamp))
                        }
                        updatedMessage
                    }
            val hasExistingUpdates = reconciledMessages != currentMessages

            // 🔥 Находим только НОВЫЕ сообщения (которых нет в текущем UI)
            val newEntities = entities.filter { it.messageId !in existingIds }

            val newMessages =
                    if (newEntities.isNotEmpty()) {
                        val semaphore = Semaphore(DECRYPT_PARALLELISM)
                        coroutineScope {
                            newEntities
                                    .map { entity ->
                                        async { semaphore.withPermit { entityToChatMessage(entity) } }
                                    }
                                    .awaitAll()
                        }
                    } else {
                        emptyList()
                    }

            if (hasExistingUpdates || newMessages.isNotEmpty()) {
                // 🔥 ДОБАВЛЯЕМ новые + применяем статусные апдейты к существующим
                val updatedMessages =
                        sortMessagesAscending((reconciledMessages + newMessages).distinctBy { it.id })

                // 🔥 Обновляем кэш: сохраняем старые страницы + применяем новые статусы/время
                val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
                val mergedCache = sortMessagesAscending((existingCache + updatedMessages).distinctBy { it.id })
                updateCacheWithLimit(account, dialogKey, mergedCache)

                withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages }
            }

            hasMoreMessages = entities.size >= initialPageSize
            // 🔥 ИСПРАВЛЕНИЕ: НЕ сбрасываем offset если уже загружено больше сообщений!
            // Это предотвращает потерю прогресса пагинации при refresh
            if (currentOffset < entities.size) {
                currentOffset = entities.size
            }

            // 👁️ Фоновые операции - НЕ помечаем как прочитанные если диалог неактивен!
            if (isDialogActive) {
                messageDao.markDialogAsRead(account, dialogKey)
            }
            // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved
            // messages
            if (isSavedMessages) {
                dialogDao.updateSavedMessagesDialogFromMessages(account)
            } else {
                dialogDao.updateDialogFromMessages(account, opponent)
            }

            // 👁️ Отправляем read receipt — как в desktop (marks read + sends packet)
            if (isDialogActive && !isSavedMessages) {
                sendReadReceiptToOpponent()
            }
        } catch (e: Exception) {}
    }

    // ✅ updateDialogCache удалён - кэш обновляется через loadMessagesFromDatabase после сохранения
    // в БД

    /**
     * 🚀 Загрузка следующей страницы (для бесконечной прокрутки) 📁 SAVED MESSAGES: Использует
     * специальные методы для saved messages
     */
    fun loadMoreMessages() {
        val account = myPublicKey ?: return
        val opponent = opponentKey ?: return

        // 📁 Проверяем является ли это Saved Messages
        val isSavedMessages = (opponent == account)

        if (!hasMoreMessages || isLoadingMessages) return
        isLoadingMessages = true

        viewModelScope.launch(Dispatchers.IO) {
            try {
                withContext(Dispatchers.Main) { _isLoadingMore.value = true }

                val dialogKey = getDialogKey(account, opponent)

                val entities =
                        if (isSavedMessages) {
                            messageDao.getMessagesForSavedDialog(
                                    account,
                                    limit = PAGINATION_PAGE_SIZE,
                                    offset = currentOffset
                            )
                        } else {
                            messageDao.getMessages(
                                    account,
                                    dialogKey,
                                    limit = PAGINATION_PAGE_SIZE,
                                    offset = currentOffset
                            )
                        }

                hasMoreMessages = entities.size >= PAGINATION_PAGE_SIZE
                currentOffset += entities.size

                if (entities.isNotEmpty()) {
                    val semaphore = Semaphore(DECRYPT_PARALLELISM)
                    val newMessages =
                            coroutineScope {
                                        entities
                                                .map { entity ->
                                                    async {
                                                        semaphore.withPermit {
                                                            entityToChatMessage(entity)
                                                        }
                                                    }
                                                }
                                                .awaitAll()
                                    }
                                    .asReversed()

                    // 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
                    // Это предотвращает потерю сообщений при повторном открытии диалога
                    val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
                    val allCachedIds = existingCache.map { it.id }.toSet()
                    val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
                    if (trulyNewMessages.isNotEmpty()) {
                        updateCacheWithLimit(
                                account,
                                dialogKey,
                                sortMessagesAscending(trulyNewMessages + existingCache)
                        )
                    }

                    // Добавляем в начало списка (старые сообщения)
                    withContext(Dispatchers.Main) {
                        _messages.value =
                                sortMessagesAscending(
                                        (newMessages + _messages.value).distinctBy { it.id }
                                )
                    }
                }

                withContext(Dispatchers.Main) { _isLoadingMore.value = false }
                isLoadingMessages = false
            } catch (e: Exception) {
                withContext(Dispatchers.Main) { _isLoadingMore.value = false }
                isLoadingMessages = false
            }
        }
    }

    /**
     * 🔥 Конвертация Entity -> ChatMessage с расшифровкой из content + chachaKey Как в архиве:
     * расшифровываем при каждой загрузке
     */
    private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage {

        // 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
        val cachedText = decryptionCache[entity.messageId]

        val privateKey = myPrivateKey
        val groupDialogKey =
                when {
                    isGroupDialogKey(entity.dialogKey) -> entity.dialogKey.trim()
                    isGroupDialogKey(entity.toPublicKey) -> entity.toPublicKey.trim()
                    else -> opponentKey?.trim()?.takeIf { isGroupDialogKey(it) }
                }
        val isGroupMessage = groupDialogKey != null || entity.chachaKey.startsWith("group:")
        val groupKey =
                if (isGroupMessage && privateKey != null) {
                    decodeStoredGroupKey(entity.chachaKey, privateKey)
                            ?: groupDialogKey?.let { resolveGroupKeyForDialog(it) }
                } else {
                    null
                }

        // Расшифровываем сообщение из content + chachaKey
        var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
        var displayText =
                if (cachedText != null) {
                    cachedText
                } else
                        try {
                            if (isGroupMessage && groupKey != null && entity.content.isNotEmpty()) {
                                val decrypted = CryptoManager.decryptWithPassword(entity.content, groupKey)
                                if (decrypted != null) {
                                    decryptionCache[entity.messageId] = decrypted
                                    decrypted
                                } else {
                                    safePlainMessageFallback(entity.plainMessage)
                                }
                            } else if (
                                            privateKey != null &&
                                                    entity.content.isNotEmpty() &&
                                                    entity.chachaKey.isNotEmpty() &&
                                                    !entity.chachaKey.startsWith("group:")
                            ) {
                                // Расшифровываем как в архиве: content + chachaKey + privateKey
                                // 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
                                // reply
                                val decryptResult =
                                        MessageCrypto.decryptIncomingFull(
                                                ciphertext = entity.content,
                                                encryptedKey = entity.chachaKey,
                                                myPrivateKey = privateKey
                                        )
                                plainKeyAndNonce = decryptResult.plainKeyAndNonce
                                val decrypted = decryptResult.plaintext
                                // 🚀 Сохраняем в кэш
                                decryptionCache[entity.messageId] = decrypted
                                decrypted
                            } else {
                                // Fallback на расшифровку plainMessage с приватным ключом
                                if (privateKey != null && entity.plainMessage.isNotEmpty()) {
                                    try {
                                        val decrypted =
                                                CryptoManager.decryptWithPassword(
                                                        entity.plainMessage,
                                                        privateKey
                                                )
                                        if (decrypted != null) {
                                            decryptionCache[entity.messageId] = decrypted
                                            decrypted
                                        } else {
                                            safePlainMessageFallback(entity.plainMessage)
                                        }
                                    } catch (e: Exception) {
                                        safePlainMessageFallback(entity.plainMessage)
                                    }
                                } else {
                                    safePlainMessageFallback(entity.plainMessage)
                                }
                            }
                        } catch (e: Exception) {
                            // Пробуем расшифровать plainMessage
                            if (privateKey != null && entity.plainMessage.isNotEmpty()) {
                                try {
                                    val decrypted = CryptoManager.decryptWithPassword(
                                            entity.plainMessage,
                                            privateKey
                                    )
                                    if (decrypted != null) {
                                        decryptionCache[entity.messageId] = decrypted
                                        decrypted
                                    } else {
                                        safePlainMessageFallback(entity.plainMessage)
                                    }
                                } catch (e2: Exception) {
                                    safePlainMessageFallback(entity.plainMessage)
                                }
                            } else {
                                safePlainMessageFallback(entity.plainMessage)
                            }
                        }

        // Парсим attachments для поиска MESSAGES (цитата)
        // 🚀 P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH
        val parsedReplyResult =
                parseReplyFromAttachments(
                        attachmentsJson = entity.attachments,
                        isFromMe = entity.fromMe == 1,
                        content = entity.content,
                        chachaKey = entity.chachaKey,
                        plainKeyAndNonce = plainKeyAndNonce,
                        groupPassword = groupKey
                )
        var replyData = parsedReplyResult?.replyData
        val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList()

        // Если не нашли reply в attachments, пробуем распарсить из текста
        if (replyData == null) {
            val parseResult = parseReplyFromText(displayText)
            if (parseResult != null) {
                replyData = parseResult.first
                displayText = parseResult.second
            } else {}
        } else {}

        // Парсим все attachments (IMAGE, FILE, AVATAR)
        val parsedAttachments =
                normalizeIncomingCallAttachments(
                        parseAllAttachments(entity.attachments),
                        displayText
                )
        val finalAttachments =
                if (parsedAttachments.isEmpty() && displayText.isBlank()) {
                    parseCallAttachmentFallback(entity.attachments, entity.messageId)?.let {
                        listOf(it)
                    } ?: parsedAttachments
                } else {
                    parsedAttachments
                }

        val myKey = myPublicKey.orEmpty().trim()
        val senderKey = entity.fromPublicKey.trim()
        val senderName =
                when {
                    senderKey.isBlank() -> ""
                    senderKey == myKey -> "You"
                    isGroupMessage -> resolveGroupSenderName(senderKey)
                    else -> opponentTitle.ifBlank { opponentUsername.ifBlank { shortPublicKey(senderKey) } }
                }

        if (isGroupMessage && senderKey.isNotBlank() && senderKey != myKey) {
            requestGroupSenderNameIfNeeded(senderKey)
        }

        val chachaKeyPlainHex = plainKeyAndNonce?.joinToString("") { "%02x".format(it) } ?: ""

        return ChatMessage(
                id = entity.messageId,
                text = displayText,
                isOutgoing = entity.fromMe == 1,
                timestamp = Date(entity.timestamp),
                status = mapEntityDeliveryStatus(entity),
                replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
                forwardedMessages = forwardedMessages,
                attachments = finalAttachments,
                chachaKey = entity.chachaKey,
                chachaKeyPlainHex = chachaKeyPlainHex,
                senderPublicKey = senderKey,
                senderName = senderName
        )
    }

    private fun shortPublicKey(value: String): String {
        val trimmed = value.trim()
        if (trimmed.length <= 12) return trimmed
        return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
    }

    private fun buildGroupTypingDisplayName(baseName: String, participantsCount: Int): String {
        val normalizedName = baseName.trim()
        if (normalizedName.isBlank()) return ""
        val extraParticipants = (participantsCount - 1).coerceAtLeast(0)
        return if (extraParticipants > 0) {
            "$normalizedName and $extraParticipants"
        } else {
            normalizedName
        }
    }

    private fun resolveKnownGroupSenderName(publicKey: String): String {
        val normalizedPublicKey = publicKey.trim()
        if (normalizedPublicKey.isBlank()) return ""

        groupSenderNameCache[normalizedPublicKey]?.let { cached ->
            if (isUsableSenderName(cached, normalizedPublicKey)) return cached
        }

        val nameFromMessages =
                _messages.value.asSequence().mapNotNull { message ->
                    if (message.senderPublicKey.trim()
                                    .equals(normalizedPublicKey, ignoreCase = true)
                    ) {
                        message.senderName.trim()
                    } else {
                        null
                    }
                }.firstOrNull { isUsableSenderName(it, normalizedPublicKey) }

        if (!nameFromMessages.isNullOrBlank()) {
            groupSenderNameCache[normalizedPublicKey] = nameFromMessages
            return nameFromMessages
        }

        val cachedInfo = protocolGateway.getCachedUserInfo(normalizedPublicKey)
        val protocolName =
                cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() }
        if (isUsableSenderName(protocolName, normalizedPublicKey)) {
            groupSenderNameCache[normalizedPublicKey] = protocolName
            return protocolName
        }

        return ""
    }

    private suspend fun resolveGroupSenderName(publicKey: String): String {
        val normalizedPublicKey = publicKey.trim()
        if (normalizedPublicKey.isBlank()) return ""

        groupSenderNameCache[normalizedPublicKey]?.let { cached ->
            if (isUsableSenderName(cached, normalizedPublicKey)) return cached
        }

        val account = myPublicKey
        if (!account.isNullOrBlank()) {
            try {
                val dialog = dialogDao.getDialog(account, normalizedPublicKey)
                val localName = dialog?.opponentTitle?.trim().orEmpty()
                    .ifBlank { dialog?.opponentUsername?.trim().orEmpty() }
                if (isUsableSenderName(localName, normalizedPublicKey)) {
                    groupSenderNameCache[normalizedPublicKey] = localName
                    return localName
                }
            } catch (_: Exception) {
                // ignore
            }
        }

        val cached = protocolGateway.getCachedUserInfo(normalizedPublicKey)
        val protocolName = cached?.title?.trim().orEmpty()
            .ifBlank { cached?.username?.trim().orEmpty() }
        if (isUsableSenderName(protocolName, normalizedPublicKey)) {
            groupSenderNameCache[normalizedPublicKey] = protocolName
            return protocolName
        }

        return shortPublicKey(normalizedPublicKey)
    }

    private fun isUsableSenderName(name: String, publicKey: String): Boolean {
        if (name.isBlank()) return false
        val normalized = name.trim()
        val normalizedPublicKey = publicKey.trim()
        if (normalizedPublicKey.isNotBlank()) {
            if (normalized.equals(normalizedPublicKey, ignoreCase = true)) return false
            if (normalized.equals(normalizedPublicKey.take(7), ignoreCase = true)) return false
            if (normalized.equals(normalizedPublicKey.take(8), ignoreCase = true)) return false
        }
        return true
    }

    private fun requestGroupSenderNameIfNeeded(publicKey: String) {
        val normalizedPublicKey = publicKey.trim()
        if (normalizedPublicKey.isBlank()) return
        if (normalizedPublicKey == myPublicKey?.trim()) return
        groupSenderNameCache[normalizedPublicKey]?.let { cached ->
            if (isUsableSenderName(cached, normalizedPublicKey)) return
        }
        if (!groupSenderResolveRequested.add(normalizedPublicKey)) return

        viewModelScope.launch(Dispatchers.IO) {
            try {
                val resolved = protocolGateway.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L)
                val name = resolved?.title?.trim().orEmpty()
                    .ifBlank { resolved?.username?.trim().orEmpty() }
                if (!isUsableSenderName(name, normalizedPublicKey)) {
                    groupSenderResolveRequested.remove(normalizedPublicKey)
                    return@launch
                }
                groupSenderNameCache[normalizedPublicKey] = name
                withContext(Dispatchers.Main) {
                    if (_opponentTyping.value &&
                            typingSenderPublicKey.equals(normalizedPublicKey, ignoreCase = true)
                    ) {
                        _typingDisplayName.value =
                                buildGroupTypingDisplayName(name, typingUsersCount)
                    }
                    _messages.update { current ->
                        current.map { message ->
                            if (message.senderPublicKey.trim() == normalizedPublicKey &&
                                    message.senderName != name
                            ) {
                                message.copy(senderName = name)
                            } else {
                                message
                            }
                        }
                    }
                }
            } catch (_: Exception) {
                groupSenderResolveRequested.remove(normalizedPublicKey)
            }
        }
    }

    /**
     * Никогда не показываем в UI сырые шифротексты (`CHNK:`/`iv:ciphertext`) как текст сообщения.
     * Это предотвращает появление "ключа" в подписи медиа при сбоях дешифровки.
     */
    private fun safePlainMessageFallback(raw: String): String {
        if (raw.isBlank()) return ""
        return if (isProbablyEncryptedPayload(raw)) "" else raw
    }

    private fun isProbablyEncryptedPayload(value: String): Boolean {
        val trimmed = value.trim()
        if (trimmed.startsWith("CHNK:")) return true

        val parts = trimmed.split(":")
        if (parts.size != 2) return false
        return parts.all { part ->
            part.length >= 16 && part.all { ch ->
                ch.isLetterOrDigit() || ch == '+' || ch == '/' || ch == '='
            }
        }
    }

    private fun parseAttachmentType(attachment: JSONObject): AttachmentType {
        val typeValue = parseAttachmentTypeValue(attachment.opt("type"))
        val parsedType = AttachmentType.fromInt(typeValue)
        if (parsedType != AttachmentType.UNKNOWN) {
            return parsedType
        }

        val preview = attachment.optString("preview", "")
        val blob = attachment.optString("blob", "")
        val width = attachment.optInt("width", 0)
        val height = attachment.optInt("height", 0)
        val attachmentId = attachment.optString("id", "")
        val transportObj = attachment.optJSONObject("transport")
        val transportTag =
                attachment.optString(
                        "transportTag",
                        attachment.optString(
                                "transport_tag",
                                transportObj?.optString("transport_tag", "") ?: ""
                        )
                )

        return inferLegacyAttachmentType(
                attachmentId = attachmentId,
                preview = preview,
                blob = blob,
                width = width,
                height = height,
                transportTag = transportTag
        )
    }

    private fun parseAttachmentTypeValue(rawType: Any?): Int {
        return when (rawType) {
            is Number -> rawType.toInt()
            is String -> {
                val normalized = rawType.trim()
                normalized.toIntOrNull()
                        ?: run {
                            val token =
                                    normalized.lowercase(Locale.ROOT)
                                            .replace('-', '_')
                                            .replace(' ', '_')
                            when (token) {
                                "image", "photo", "picture" -> AttachmentType.IMAGE.value
                                "messages", "message", "reply", "forward", "forwarded" ->
                                        AttachmentType.MESSAGES.value
                                "file", "document", "doc" -> AttachmentType.FILE.value
                                "avatar", "profile_photo", "profile_avatar" -> AttachmentType.AVATAR.value
                                "call", "phone_call" -> AttachmentType.CALL.value
                                "voice", "voice_message", "voice_note", "audio", "audio_message", "audio_note", "audiomessage", "audionote" ->
                                        AttachmentType.VOICE.value
                                "video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video_message", "video" ->
                                        AttachmentType.VIDEO_CIRCLE.value
                                else -> -1
                            }
                        }
            }
            else -> -1
        }
    }

    private fun inferLegacyAttachmentType(
            attachmentId: String,
            preview: String,
            blob: String,
            width: Int,
            height: Int,
            transportTag: String
    ): AttachmentType {
        if (isLikelyMessagesAttachmentPayload(preview = preview, blob = blob)) {
            return AttachmentType.MESSAGES
        }
        if (blob.isBlank() && width <= 0 && height <= 0 && isLikelyCallAttachmentPreview(preview)) {
            return AttachmentType.CALL
        }
        if (isLikelyVideoCircleAttachmentPreview(preview = preview, attachmentId = attachmentId)) {
            return AttachmentType.VIDEO_CIRCLE
        }
        if (isLikelyVoiceAttachmentPreview(preview = preview, attachmentId = attachmentId)) {
            return AttachmentType.VOICE
        }
        if (width > 0 || height > 0) {
            return AttachmentType.IMAGE
        }
        if (isLikelyFileAttachmentPreview(preview) || transportTag.isNotBlank()) {
            return AttachmentType.FILE
        }
        return AttachmentType.UNKNOWN
    }

    private fun isLikelyMessagesAttachmentPayload(preview: String, blob: String): Boolean {
        val payload = blob.ifBlank { preview }.trim()
        if (payload.isEmpty()) return false
        if (!payload.startsWith("{") && !payload.startsWith("[")) return false

        val objectCandidate =
                runCatching {
                            if (payload.startsWith("[")) {
                                JSONArray(payload).optJSONObject(0)
                            } else {
                                JSONObject(payload)
                            }
                        }
                        .getOrNull()
                        ?: return false

        return objectCandidate.has("message_id") ||
                objectCandidate.has("publicKey") ||
                objectCandidate.has("message") ||
                objectCandidate.has("attachments")
    }

    private fun isLikelyVoiceAttachmentPreview(preview: String, attachmentId: String): Boolean {
        val id = attachmentId.trim().lowercase(Locale.ROOT)
        if (id.startsWith("voice_") || id.startsWith("voice-") || id.startsWith("audio_") || id.startsWith("audio-")) {
            return true
        }

        val normalized = preview.trim()
        if (normalized.isEmpty()) return false

        val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
        if (duration < 0) return false

        val tail = normalized.substringAfter("::", "").trim()
        if (tail.isEmpty()) return true
        if (tail.startsWith("video/", ignoreCase = true)) return false
        if (tail.startsWith("audio/", ignoreCase = true)) return true
        if (!tail.contains(",")) return false

        val values = tail.split(",")
        return values.size >= 2 && values.all { it.trim().toFloatOrNull() != null }
    }

    private fun isLikelyVideoCircleAttachmentPreview(preview: String, attachmentId: String): Boolean {
        val id = attachmentId.trim().lowercase(Locale.ROOT)
        if (id.startsWith("video_circle_") || id.startsWith("video-circle-")) {
            return true
        }

        val normalized = preview.trim()
        if (normalized.isEmpty()) return false

        val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
        if (duration < 0) return false

        val mime = normalized.substringAfter("::", "").trim().lowercase(Locale.ROOT)
        return mime.startsWith("video/")
    }

    private fun isLikelyFileAttachmentPreview(preview: String): Boolean {
        val normalized = preview.trim()
        if (normalized.isEmpty()) return false
        val parts = normalized.split("::")
        if (parts.size < 2) return false

        val first = parts[0]
        return when {
            parts.size >= 3 && forwardUuidRegex.matches(first) && parts[1].toLongOrNull() != null -> true
            parts.size >= 2 && first.toLongOrNull() != null -> true
            parts.size >= 2 && forwardUuidRegex.matches(first) -> true
            else -> false
        }
    }

    private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
        val normalized = preview.trim()
        // Empty preview must NOT be treated as call, otherwise we get false-positive
        // "empty call" attachments in regular/group messages.
        if (normalized.isEmpty()) return false

        val tail = normalized.substringAfterLast("::", normalized).trim()
        if (tail.toIntOrNull() != null) return true

        return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
                .containsMatchIn(normalized)
    }

    private fun normalizeIncomingCallAttachments(
            attachments: List<MessageAttachment>,
            messageText: String
    ): List<MessageAttachment> {
        if (attachments.isEmpty() || messageText.isNotBlank() || attachments.size != 1) {
            return attachments
        }

        val first = attachments.first()
        if (first.blob.isNotBlank() || first.width > 0 || first.height > 0) {
            return attachments
        }
        if (!isLikelyCallAttachmentPreview(first.preview)) {
            return attachments
        }

        return when (first.type) {
            AttachmentType.CALL -> attachments
            else -> listOf(first.copy(type = AttachmentType.CALL))
        }
    }

    private fun parseCallAttachmentFallback(
            attachmentsJson: String,
            fallbackId: String
    ): MessageAttachment? {
        val array = parseAttachmentsJsonArray(attachmentsJson) ?: return null
        if (array.length() != 1) return null
        val first = array.optJSONObject(0) ?: return null

        if (first.optString("blob", "").isNotBlank()) return null
        if (first.optInt("width", 0) > 0 || first.optInt("height", 0) > 0) return null

        val preview = first.optString("preview", "")
        if (!isLikelyCallAttachmentPreview(preview)) return null
        val transportObj = first.optJSONObject("transport")
        val transportTag =
                first.optString(
                        "transportTag",
                        first.optString(
                                "transport_tag",
                                transportObj?.optString("transport_tag", "") ?: ""
                        )
                )
        val transportServer =
                first.optString(
                        "transportServer",
                        first.optString(
                                "transport_server",
                                transportObj?.optString("transport_server", "") ?: ""
                        )
                )

        return MessageAttachment(
                id = first.optString("id", "").ifBlank { "call-$fallbackId" },
                blob = "",
                type = AttachmentType.CALL,
                preview = preview,
                width = 0,
                height = 0,
                transportTag = transportTag,
                transportServer = transportServer
        )
    }

    private fun parseAttachmentsJsonArray(attachmentsJson: String): JSONArray? {
        val normalized = attachmentsJson.trim()
        if (normalized.isEmpty() || normalized == "[]") return null

        val parsedDirectArray = runCatching { JSONArray(normalized) }.getOrNull()
        if (parsedDirectArray != null) return parsedDirectArray

        val parsedDirectObject = runCatching { JSONObject(normalized) }.getOrNull()
        if (parsedDirectObject != null) {
            return JSONArray().put(parsedDirectObject)
        }

        if (normalized.length >= 2 && normalized.first() == '"' && normalized.last() == '"') {
            val unescaped =
                    runCatching { JSONObject("{\"v\":$normalized}").optString("v", "") }
                            .getOrDefault("")
                            .trim()
            if (unescaped.isNotEmpty() && unescaped != normalized) {
                runCatching { JSONArray(unescaped) }.getOrNull()?.let { return it }
                runCatching { JSONObject(unescaped) }.getOrNull()?.let { return JSONArray().put(it) }
            }
        }

        return null
    }

    /**
     * Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для
     * IMAGE - загружает blob из файловой системы если пустой в БД
     */
    private fun parseAllAttachments(attachmentsJson: String): List<MessageAttachment> {
        if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
            return emptyList()
        }

        return try {
            val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return emptyList()
            val result = mutableListOf<MessageAttachment>()
            val publicKey = myPublicKey ?: ""
            val privateKey = myPrivateKey ?: ""

            for (i in 0 until attachments.length()) {
                val attachment = attachments.getJSONObject(i)
                val attachmentType = parseAttachmentType(attachment)
                val preview = attachment.optString("preview", "")
                val hasBlob = attachment.optString("blob", "").isNotBlank()
                val hasSize = attachment.optInt("width", 0) > 0 || attachment.optInt("height", 0) > 0
                val effectiveType =
                        if (attachmentType == AttachmentType.MESSAGES &&
                                !hasBlob &&
                                !hasSize &&
                                isLikelyCallAttachmentPreview(preview)
                        ) {
                            AttachmentType.CALL
                        } else {
                            attachmentType
                        }

                // Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно
                if (effectiveType == AttachmentType.MESSAGES) continue

                var blob = attachment.optString("blob", "")
                val attachmentId = attachment.optString("id", "")
                val transportObj = attachment.optJSONObject("transport")
                val transportTag =
                        attachment.optString(
                                "transportTag",
                                attachment.optString(
                                        "transport_tag",
                                        transportObj?.optString("transport_tag", "") ?: ""
                                )
                        )
                val transportServer =
                        attachment.optString(
                                "transportServer",
                                attachment.optString(
                                        "transport_server",
                                        transportObj?.optString("transport_server", "") ?: ""
                                )
                        )

                // 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
                if ((effectiveType == AttachmentType.IMAGE ||
                                effectiveType == AttachmentType.AVATAR ||
                                effectiveType == AttachmentType.VOICE ||
                                effectiveType == AttachmentType.VIDEO_CIRCLE) &&
                                blob.isEmpty() &&
                                attachmentId.isNotEmpty()
                ) {
                    val fileBlob =
                            AttachmentFileManager.readAttachment(
                                    context = app,
                                    attachmentId = attachmentId,
                                    publicKey = publicKey,
                                    privateKey = privateKey
                            )
                    if (fileBlob != null) {
                        blob = fileBlob
                    }
                }

                result.add(
                        MessageAttachment(
                                id = attachmentId,
                                blob = blob,
                                type = effectiveType,
                                preview = preview,
                                width = attachment.optInt("width", 0),
                                height = attachment.optInt("height", 0),
                                localUri =
                                        attachment.optString(
                                                "localUri",
                                                ""
                                        ), // 🔥 Поддержка localUri из БД
                                transportTag = transportTag,
                                transportServer = transportServer
                        )
                )
            }

            result
        } catch (e: Exception) {
            emptyList()
        }
    }

    /**
     * Парсинг reply из текста сообщения (fallback формат) Формат: "🇵 Reply: "текст
     * цитаты"\n\nосновной текст" Возвращает Pair(ReplyData, очищенный текст) или null
     */
    private fun parseReplyFromText(text: String): Pair<ReplyData, String>? {
        // Паттерн: начинается с эмодзи флага + "Reply:" + текст в кавычках + перенос строки
        val replyPattern =
                Regex("^[🇵🔁↩️]\\s*Reply:\\s*\"(.+?)\"\\s*\\n+(.*)$", RegexOption.DOT_MATCHES_ALL)
        val match = replyPattern.find(text) ?: return null

        val replyText = match.groupValues[1]
        val mainText = match.groupValues[2].trim()

        return Pair(
                ReplyData(
                        messageId = "",
                        senderName = opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
                        text = replyText,
                        isFromMe = false, // Мы не знаем кто автор из fallback формата
                        senderPublicKey = opponentKey ?: "",
                        recipientPrivateKey = myPrivateKey ?: ""
                ),
                mainText
        )
    }

    /**
     * Парсинг MESSAGES attachment для извлечения данных цитаты Формат: [{"message_id": "...",
     * "publicKey": "...", "message": "..."}] 🔥 ВАЖНО: Если blob зашифрован (формат
     * "iv:ciphertext"), расшифровываем его
     */
    /** Result of parsing reply/forward attachments */
    data class ParsedReplyResult(
        val replyData: ReplyData? = null,
        val forwardedMessages: List<ReplyData> = emptyList()
    )

    private fun replyLog(msg: String) {
        try {
            val ctx = app
            val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
            val dir = java.io.File(ctx.filesDir, "crash_reports")
            if (!dir.exists()) dir.mkdirs()
            java.io.File(dir, "rosettadev1.txt").appendText("$ts [Reply] $msg\n")
        } catch (_: Exception) {}
    }

    private suspend fun parseReplyFromAttachments(
            attachmentsJson: String,
            isFromMe: Boolean,
            content: String,
            chachaKey: String,
            plainKeyAndNonce: ByteArray? =
                    null, // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
            groupPassword: String? = null
    ): ParsedReplyResult? {

        if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
            return null
        }

        return try {
            val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return null

            for (i in 0 until attachments.length()) {
                val attachment = attachments.getJSONObject(i)
                val type = parseAttachmentType(attachment)

                // MESSAGES = 1 (цитата)
                if (type == AttachmentType.MESSAGES) {
                    replyLog("=== PARSE REPLY: isFromMe=$isFromMe, hasGroup=${groupPassword != null}, chachaKey=${chachaKey.take(12)}, hasPlainKey=${plainKeyAndNonce != null} ===")

                    // Данные могут быть в blob или preview
                    var dataJson = attachment.optString("blob", "")

                    if (dataJson.isEmpty()) {
                        dataJson = attachment.optString("preview", "")
                        replyLog("  blob empty, using preview")
                    }

                    if (dataJson.isEmpty()) {
                        replyLog("  BOTH empty → skip")
                        continue
                    }

                    replyLog("  dataJson.len=${dataJson.length}, colons=${dataJson.count { it == ':' }}, starts='${dataJson.take(20)}'")

                    // 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат
                    // "iv:ciphertext"
                    val colonCount = dataJson.count { it == ':' }

                    if (dataJson.contains(":") && dataJson.split(":").size == 2) {
                        val privateKey = myPrivateKey
                        var decryptionSuccess = false
                        replyLog("  encrypted format detected (iv:cipher), trying decrypt methods...")

                        // 🔥 Способ 0: Группа — blob шифруется ключом группы
                        if (groupPassword != null && !decryptionSuccess) {
                            replyLog("  [0] group raw key (len=${groupPassword.length})")
                            try {
                                val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
                                if (decrypted != null) {
                                    dataJson = decrypted
                                    decryptionSuccess = true
                                    replyLog("  [0] OK raw key")
                                } else {
                                    replyLog("  [0] raw key → null")
                                }
                            } catch (e: Exception) { replyLog("  [0] raw key EXCEPTION: ${e.message}") }
                            // Fallback: Desktop v1.2.1+ шифрует hex-версией ключа
                            if (!decryptionSuccess) {
                                try {
                                    val hexKey = groupPassword.toByteArray(Charsets.ISO_8859_1)
                                        .joinToString("") { "%02x".format(it.toInt() and 0xff) }
                                    replyLog("  [0] trying hex key (len=${hexKey.length})")
                                    val decrypted = CryptoManager.decryptWithPassword(dataJson, hexKey)
                                    if (decrypted != null) {
                                        dataJson = decrypted
                                        decryptionSuccess = true
                                        replyLog("  [0] OK hex key")
                                    } else {
                                        replyLog("  [0] hex key → null")
                                    }
                                } catch (e: Exception) { replyLog("  [0] hex key EXCEPTION: ${e.message}") }
                            }
                        }

                        // 🔥 Способ 1: Пробуем расшифровать с приватным ключом
                        if (privateKey != null && !decryptionSuccess) {
                            replyLog("  [1] private key")
                            try {
                                val decrypted =
                                        CryptoManager.decryptWithPassword(dataJson, privateKey)
                                if (decrypted != null) {
                                    dataJson = decrypted
                                    decryptionSuccess = true
                                }
                            } catch (e: Exception) {}
                        }

                        // 🔥 Способ 2: Пробуем расшифровать с ChaCha ключом сообщения (для
                        // входящих)
                        if (!decryptionSuccess &&
                                        content.isNotEmpty() &&
                                        chachaKey.isNotEmpty() &&
                                        privateKey != null
                        ) {
                            try {
                                val decrypted =
                                        MessageCrypto.decryptAttachmentBlob(
                                                dataJson,
                                                chachaKey,
                                                privateKey
                                        )
                                if (decrypted != null) {
                                    dataJson = decrypted
                                    decryptionSuccess = true
                                }
                            } catch (e: Exception) {}
                        }

                        // � Способ 3: Используем plainKeyAndNonce из основной расшифровки (без
                        // повторного ECDH)
                        if (!decryptionSuccess && plainKeyAndNonce != null) {
                            try {
                                val decrypted =
                                        MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce)
                                if (decrypted.isNotEmpty()) {
                                    dataJson = decrypted
                                    decryptionSuccess = true
                                }
                            } catch (e: Exception) {}
                        }

                        // 🔥 Способ 4 (fallback): Полный decryptIncomingFull если нет
                        // plainKeyAndNonce
                        if (!decryptionSuccess &&
                                        plainKeyAndNonce == null &&
                                        content.isNotEmpty() &&
                                        chachaKey.isNotEmpty() &&
                                        privateKey != null
                        ) {
                            try {
                                val decryptResult =
                                        MessageCrypto.decryptIncomingFull(
                                                content,
                                                chachaKey,
                                                privateKey
                                        )
                                val keyAndNonce = decryptResult.plainKeyAndNonce
                                val decrypted =
                                        MessageCrypto.decryptReplyBlob(dataJson, keyAndNonce)
                                if (decrypted.isNotEmpty()) {
                                    dataJson = decrypted
                                    decryptionSuccess = true
                                }
                            } catch (e: Exception) {}
                        }

                        replyLog("  FINAL: decryptionSuccess=$decryptionSuccess")
                        if (!decryptionSuccess) {
                            replyLog("  ALL METHODS FAILED → skip")
                            continue
                        }
                    } else {
                        replyLog("  NOT encrypted (no iv:cipher format), treating as plain JSON")
                    }

                    val messagesArray =
                            try {
                                JSONArray(dataJson)
                            } catch (e: Exception) {
                                replyLog("  JSON parse FAILED: ${e.message?.take(50)}")
                                replyLog("  dataJson preview: '${dataJson.take(80)}'")
                                continue
                            }

                    replyLog("  JSON OK: ${messagesArray.length()} messages")
                    if (messagesArray.length() > 0) {
                        val account = myPublicKey ?: return null
                        val dialogKey = getDialogKey(account, opponentKey ?: "")

                        val firstMsg = messagesArray.getJSONObject(0)
                        val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1
                        replyLog("  isForwardedSet=$isForwardedSet, firstMsg keys=${firstMsg.keys().asSequence().toList()}")

                        if (isForwardedSet) {
                            // 🔥 Parse ALL forwarded messages (desktop parity)
                            val forwardedList = mutableListOf<ReplyData>()
                            for (idx in 0 until messagesArray.length()) {
                                val fwdMsg = messagesArray.getJSONObject(idx)
                                val fwdPublicKey = fwdMsg.optString("publicKey", "")
                                val fwdText = fwdMsg.optString("message", "")
                                val fwdMessageId = fwdMsg.optString("message_id", "")
                                val fwdTimestamp = fwdMsg.optLong("timestamp", 0L)
                                val fwdSenderName = fwdMsg.optString("senderName", "")
                                val fwdChachaKeyPlain = fwdMsg.optString("chacha_key_plain", "")
                                val fwdIsFromMe = fwdPublicKey == myPublicKey

                                // Parse attachments from this forwarded message
                                val fwdAttachments = mutableListOf<MessageAttachment>()
                                try {
                                    val attArray = fwdMsg.optJSONArray("attachments")
                                    if (attArray != null) {
                                        for (j in 0 until attArray.length()) {
                                            val attJson = attArray.getJSONObject(j)
                                            val attId = attJson.optString("id", "")
                                            val attType = AttachmentType.fromInt(attJson.optInt("type", 0))
                                            val attPreview = attJson.optString("preview", "")
                                            val attBlob = attJson.optString("blob", "")
                                            val attWidth = attJson.optInt("width", 0)
                                            val attHeight = attJson.optInt("height", 0)
                                            val attLocalUri = attJson.optString("localUri", "")
                                            val transportObj = attJson.optJSONObject("transport")
                                            val attTransportTag =
                                                    attJson.optString(
                                                            "transportTag",
                                                            attJson.optString(
                                                                    "transport_tag",
                                                                    transportObj?.optString(
                                                                                    "transport_tag",
                                                                                    ""
                                                                            )
                                                                            ?: ""
                                                            )
                                                    )
                                            val attTransportServer =
                                                    attJson.optString(
                                                            "transportServer",
                                                            attJson.optString(
                                                                    "transport_server",
                                                                    transportObj?.optString(
                                                                                    "transport_server",
                                                                                    ""
                                                                            )
                                                                            ?: ""
                                                            )
                                                    )
                                            if (attId.isNotEmpty()) {
                                                fwdAttachments.add(MessageAttachment(
                                                    id = attId, type = attType, preview = attPreview,
                                                    blob = attBlob, width = attWidth, height = attHeight,
                                                    localUri = attLocalUri,
                                                    transportTag = attTransportTag,
                                                    transportServer = attTransportServer
                                                ))
                                            }
                                        }
                                    }
                                } catch (_: Exception) {}

                                val senderDisplayName = fwdSenderName.ifEmpty {
                                    if (fwdIsFromMe) "You"
                                    else if (fwdPublicKey.isNotEmpty()) {
                                        // 1. Try local DB (existing dialog)
                                        val dbName = try {
                                            val dialog = dialogDao.getDialog(account, fwdPublicKey)
                                            dialog?.opponentTitle?.ifEmpty { dialog.opponentUsername }?.ifEmpty { null }
                                        } catch (_: Exception) { null }
                                        
                                        // 2. Try runtime cache (previously resolved)
                                        val cachedName = dbName ?: protocolGateway.getCachedUserName(fwdPublicKey)
                                        
                                        // 3. Server resolve via PacketSearch (like desktop useUserInformation)
                                        val serverName = if (cachedName == null) {
                                            try {
                                                protocolGateway.resolveUserName(fwdPublicKey, 3000)
                                            } catch (_: Exception) { null }
                                        } else null
                                        
                                        cachedName ?: serverName ?: "User"
                                    } else "User"
                                }

                                forwardedList.add(ReplyData(
                                    messageId = fwdMessageId,
                                    senderName = senderDisplayName,
                                    text = resolveReplyPreviewText(fwdText, fwdAttachments),
                                    isFromMe = fwdIsFromMe,
                                    isForwarded = true,
                                    forwardedFromName = senderDisplayName,
                                    attachments = fwdAttachments,
                                    senderPublicKey = fwdPublicKey.ifEmpty { if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "" },
                                    recipientPrivateKey = myPrivateKey ?: "",
                                    chachaKeyPlainHex = fwdChachaKeyPlain
                                ))
                            }
                            replyLog("  RESULT: forwarded ${forwardedList.size} messages")
                            return ParsedReplyResult(
                                replyData = forwardedList.firstOrNull(),
                                forwardedMessages = forwardedList
                            )
                        }

                        // Regular reply (not forwarded) — use first message only
                        val replyMessage = messagesArray.getJSONObject(0)
                        val replyPublicKey = replyMessage.optString("publicKey", "")
                        val replyText = replyMessage.optString("message", "")
                        val replyMessageIdFromJson = replyMessage.optString("message_id", "")
                        val replyTimestamp = replyMessage.optLong("timestamp", 0L)
                        val senderNameFromJson = replyMessage.optString("senderName", "")
                        val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "")

                        // 🔥 Detect forward:
                        // - explicit "forwarded" flag always wins
                        // - third-party heuristic applies ONLY for direct dialogs
                        //   (in groups reply author is naturally "third-party", and that must remain a reply)
                        val isGroupContext = isGroupDialogKey(opponentKey ?: "") || isGroupDialogKey(dialogKey)
                        val isFromThirdPartyDirect = !isGroupContext &&
                                replyPublicKey.isNotEmpty() &&
                                replyPublicKey != myPublicKey &&
                                replyPublicKey != opponentKey
                        val isForwarded =
                                replyMessage.optBoolean("forwarded", false) || isFromThirdPartyDirect

                        // 📸 Парсим attachments из JSON reply (как в Desktop)
                        val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
                        try {
                            val attachmentsArray = replyMessage.optJSONArray("attachments")
                            if (attachmentsArray != null && attachmentsArray.length() > 0) {
                                for (j in 0 until attachmentsArray.length()) {
                                    val attJson = attachmentsArray.getJSONObject(j)
                                    val attId = attJson.optString("id", "")
                                    val attType = AttachmentType.fromInt(attJson.optInt("type", 0))
                                    val attPreview = attJson.optString("preview", "")
                                    val attBlob = attJson.optString("blob", "")
                                    val attWidth = attJson.optInt("width", 0)
                                    val attHeight = attJson.optInt("height", 0)
                                    val attLocalUri = attJson.optString("localUri", "")
                                    val transportObj = attJson.optJSONObject("transport")
                                    val attTransportTag =
                                            attJson.optString(
                                                    "transportTag",
                                                    attJson.optString(
                                                            "transport_tag",
                                                            transportObj?.optString(
                                                                            "transport_tag",
                                                                            ""
                                                                    )
                                                                    ?: ""
                                                    )
                                            )
                                    val attTransportServer =
                                            attJson.optString(
                                                    "transportServer",
                                                    attJson.optString(
                                                            "transport_server",
                                                            transportObj?.optString(
                                                                            "transport_server",
                                                                            ""
                                                                    )
                                                                    ?: ""
                                                    )
                                            )

                                    if (attId.isNotEmpty()) {
                                        replyAttachmentsFromJson.add(
                                                MessageAttachment(
                                                        id = attId,
                                                        type = attType,
                                                        preview = attPreview,
                                                        blob = attBlob,
                                                        width = attWidth,
                                                        height = attHeight,
                                                        localUri = attLocalUri,
                                                        transportTag = attTransportTag,
                                                        transportServer = attTransportServer
                                                )
                                        )
                                    }
                                }
                            }
                        } catch (e: Exception) {}

                        // 🔥 ВАЖНО: message_id из JSON может не совпадать с messageId в Android БД!
                        // Пытаемся найти реальный messageId в текущих сообщениях по тексту и
                        // timestamp
                        val realMessageId =
                                try {
                                    // Ищем сообщение в БД по публичному ключу, тексту и timestamp
                                    // (с допуском ±5 секунд)
                                    messageDao.findMessageByContent(
                                                    account = account,
                                                    dialogKey = dialogKey,
                                                    fromPublicKey = replyPublicKey,
                                                    timestampFrom = replyTimestamp - 5000,
                                                    timestampTo = replyTimestamp + 5000
                                            )
                                            ?.messageId
                                            ?: replyMessageIdFromJson
                                } catch (e: Exception) {
                                    replyMessageIdFromJson
                                }

                        // Определяем, кто автор цитируемого сообщения
                        val isReplyFromMe = replyPublicKey == myPublicKey

                        // Загружаем оригинальное сообщение из БД для attachments и chachaKey
                        val originalMessage = try {
                            messageDao.findMessageByContent(
                                    account = account,
                                    dialogKey = dialogKey,
                                    fromPublicKey = replyPublicKey,
                                    timestampFrom = replyTimestamp - 5000,
                                    timestampTo = replyTimestamp + 5000
                            )
                        } catch (_: Exception) { null }

                        val originalChachaKey = originalMessage?.chachaKey ?: ""

                        val originalAttachments =
                                if (replyAttachmentsFromJson.isNotEmpty()) {
                                    replyAttachmentsFromJson
                                } else {
                                    try {
                                        if (originalMessage != null &&
                                                        originalMessage.attachments.isNotEmpty()
                                        ) {
                                            parseAllAttachments(originalMessage.attachments)
                                        } else {
                                            emptyList()
                                        }
                                    } catch (e: Exception) {
                                        emptyList()
                                    }
                                }

                        // 🔥 Resolve sender name by publicKey (like desktop useUserInformation)
                        // Always resolve from replyPublicKey, not hardcode opponentTitle
                        val resolvedSenderName = if (isReplyFromMe) {
                            "You"
                        } else if (replyPublicKey == opponentKey) {
                            // Reply to opponent's message in this chat — use known opponent info
                            opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
                        } else if (replyPublicKey.isNotEmpty()) {
                            // Third-party publicKey — resolve like desktop useUserInformation
                            senderNameFromJson.ifEmpty {
                                // 1. Try local DB (existing dialog with this user)
                                val dbName = try {
                                    val fwdDialog = dialogDao.getDialog(account, replyPublicKey)
                                    fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null }
                                } catch (_: Exception) { null }
                                // 2. Try runtime cache
                                val cachedName = dbName ?: protocolGateway.getCachedUserName(replyPublicKey)
                                // 3. Server resolve via PacketSearch (like desktop useUserInformation)
                                val serverName = if (cachedName == null) {
                                    try {
                                        protocolGateway.resolveUserName(replyPublicKey, 3000)
                                    } catch (_: Exception) { null }
                                } else null
                                cachedName ?: serverName ?: "User"
                            }
                        } else {
                            senderNameFromJson.ifEmpty { "User" }
                        }

                        // For forwarded messages, determine the forwardedFromName
                        val forwardFromDisplay = if (isForwarded) resolvedSenderName else ""

                        val result =
                                ReplyData(
                                        messageId = realMessageId,
                                        senderName = resolvedSenderName,
                                        text = resolveReplyPreviewText(replyText, originalAttachments),
                                        isFromMe = isReplyFromMe,
                                        isForwarded = isForwarded,
                                        forwardedFromName = forwardFromDisplay,
                                        attachments = originalAttachments,
                                        senderPublicKey = replyPublicKey.ifEmpty {
                                                if (isReplyFromMe) myPublicKey ?: ""
                                                else opponentKey ?: ""
                                        },
                                        recipientPrivateKey = myPrivateKey ?: "",
                                        chachaKey = originalChachaKey,
                                        chachaKeyPlainHex = chachaKeyPlainFromJson
                                )

                        // 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
                        // so it renders with "Forwarded from" header (like multi-forward)
                        if (isForwarded) {
                            replyLog("  RESULT: single forward from=${result.senderName}")
                            return ParsedReplyResult(
                                replyData = result,
                                forwardedMessages = listOf(result)
                            )
                        }
                        replyLog("  RESULT: reply from=${result.senderName}, text='${result.text.take(30)}'")
                        return ParsedReplyResult(replyData = result)
                    } else {}
                } else {}
            }
            null
        } catch (e: Exception) {
            null
        }
    }

    /**
     * Получить ключ диалога для группировки сообщений 📁 SAVED MESSAGES: Для saved messages
     * (account == opponent) возвращает просто account
     */
    private fun getDialogKey(account: String, opponent: String): String {
        if (isGroupDialogKey(opponent)) {
            return opponent.trim()
        }
        // Для saved messages dialog_key = просто publicKey
        if (account == opponent) {
            return account
        }
        // Для обычных диалогов - сортируем ключи
        return if (account < opponent) {
            "$account:$opponent"
        } else {
            "$opponent:$account"
        }
    }

    private fun isGroupDialogKey(value: String): Boolean {
        val normalized = value.trim().lowercase()
        return normalized.startsWith("#group:") || normalized.startsWith("group:")
    }

    private fun normalizeGroupId(value: String): String {
        val trimmed = value.trim()
        return when {
            trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
            trimmed.startsWith("group:", ignoreCase = true) -> trimmed.substringAfter(':').trim()
            else -> trimmed
        }
    }

    private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
        return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
    }

    private fun decodeStoredGroupKey(stored: String, privateKey: String): String? {
        if (!stored.startsWith("group:")) return null
        val encoded = stored.removePrefix("group:")
        if (encoded.isBlank()) return null
        return CryptoManager.decryptWithPassword(encoded, privateKey)
    }

    private suspend fun resolveGroupKeyForDialog(dialogPublicKey: String): String? {
        val account = myPublicKey ?: return null
        val privateKey = myPrivateKey ?: return null
        val normalizedDialog = dialogPublicKey.trim()
        if (!isGroupDialogKey(normalizedDialog)) return null

        groupKeyCache[normalizedDialog]?.let { return it }

        val groupId = normalizeGroupId(normalizedDialog)
        if (groupId.isBlank()) return null

        val group = groupDao.getGroup(account, groupId) ?: return null
        val decrypted = CryptoManager.decryptWithPassword(group.key, privateKey) ?: return null
        groupKeyCache[normalizedDialog] = decrypted
        return decrypted
    }

    internal suspend fun buildEncryptionContext(
            plaintext: String,
            recipient: String,
            privateKey: String
    ): OutgoingEncryptionContext? {
        return if (isGroupDialogKey(recipient)) {
            val groupKey = resolveGroupKeyForDialog(recipient) ?: return null
            OutgoingEncryptionContext(
                    encryptedContent = CryptoManager.encryptWithPassword(plaintext, groupKey),
                    encryptedKey = "",
                    aesChachaKey = CryptoManager.encryptWithPassword(groupKey, privateKey),
                    plainKeyAndNonce = null,
                    attachmentPassword = groupKey,
                    isGroup = true
            )
        } else {
            val encryptResult = MessageCrypto.encryptForSending(plaintext, recipient)
            OutgoingEncryptionContext(
                    encryptedContent = encryptResult.ciphertext,
                    encryptedKey = encryptResult.encryptedKey,
                    aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
                    plainKeyAndNonce = encryptResult.plainKeyAndNonce,
                    // Desktop parity: attachments are encrypted with key+nonce HEX password.
                    attachmentPassword =
                            encryptResult.plainKeyAndNonce.joinToString("") { "%02x".format(it) },
                    isGroup = false
            )
        }
    }

    internal fun encryptAttachmentPayloadForTextSend(
            payload: String,
            context: OutgoingEncryptionContext
    ): String {
        return forwardCoordinator.encryptAttachmentPayloadForTextSend(
                payload = payload,
                context = context
        )
    }

    internal fun forwardAttachmentRewriteKeyForTextSend(
            messageId: String,
            attachmentId: String
    ): String {
        return forwardCoordinator.forwardAttachmentRewriteKeyForTextSend(
                messageId = messageId,
                attachmentId = attachmentId
        )
    }

    internal suspend fun prepareForwardAttachmentRewritesForTextSend(
            sourceMessages: List<ReplyMessage>,
            encryptionContext: OutgoingEncryptionContext,
            privateKey: String,
            isSavedMessages: Boolean,
            timestamp: Long
    ): Pair<Map<String, MessageAttachment>, Set<String>> {
        return forwardCoordinator.prepareForwardAttachmentRewritesForTextSend(
                sourceMessages = sourceMessages,
                encryptionContext = encryptionContext,
                privateKey = privateKey,
                isSavedMessages = isSavedMessages,
                timestamp = timestamp
        )
    }

    /** Обновить текст ввода */
    fun updateInputText(text: String) {
        if (_inputText.value == text) return
        _inputText.value = text

        val key = opponentKey ?: return
        draftSaveJob?.cancel()
        draftSaveJob =
                viewModelScope.launch(Dispatchers.Default) {
                    delay(DRAFT_SAVE_DEBOUNCE_MS)
                    // Если за время debounce текст изменился — сохраняем только свежую версию.
                    if (_inputText.value != text) return@launch
                    com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
                }
    }

    private fun resolveReplyPreviewText(
            text: String,
            attachments: List<MessageAttachment>
    ): String {
        if (text.isNotBlank()) return text
        return when {
            attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
            attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
            else -> text
        }
    }

    /**
     * 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
     * правильного отображения цитаты
     */
    fun setReplyMessages(messages: List<ChatMessage>) {
        val sender = myPublicKey ?: ""
        val opponent = opponentKey ?: ""

        _replyMessages.value =
                messages.map { msg ->
                    val resolvedPublicKey =
                            msg.senderPublicKey.trim().ifEmpty {
                                if (msg.isOutgoing) sender else opponent
                            }
                    val resolvedAttachments =
                            msg.attachments
                                    .filter { it.type != AttachmentType.MESSAGES }
                    ReplyMessage(
                            messageId = msg.id,
                            text = resolveReplyPreviewText(msg.text, resolvedAttachments),
                            timestamp = msg.timestamp.time,
                            isOutgoing = msg.isOutgoing,
                            publicKey = resolvedPublicKey,
                            senderName = msg.senderName,
                            attachments = resolvedAttachments,
                            chachaKeyPlainHex = msg.chachaKeyPlainHex
                    )
                }
        _isForwardMode.value = false
    }

    /** 🔥 Установить сообщения для Forward */
    fun setForwardMessages(messages: List<ChatMessage>) {
        val sender = myPublicKey ?: ""
        val opponent = opponentKey ?: ""

        _replyMessages.value =
                messages.map { msg ->
                    val resolvedPublicKey =
                            msg.senderPublicKey.trim().ifEmpty {
                                if (msg.isOutgoing) sender else opponent
                            }
                    val resolvedAttachments =
                            msg.attachments
                                    .filter { it.type != AttachmentType.MESSAGES }
                    ReplyMessage(
                            messageId = msg.id,
                            text = resolveReplyPreviewText(msg.text, resolvedAttachments),
                            timestamp = msg.timestamp.time,
                            isOutgoing = msg.isOutgoing,
                            publicKey = resolvedPublicKey,
                            senderName = msg.senderName,
                            attachments = resolvedAttachments,
                            chachaKeyPlainHex = msg.chachaKeyPlainHex
                    )
                }
        _isForwardMode.value = true
    }

    /** 🔥 Очистить reply/forward */
    fun clearReplyMessages() {
        _replyMessages.value = emptyList()
        _isForwardMode.value = false
    }

    // ═══════════════════════════════════════════════════════════
    // 📌 PINNED MESSAGES
    // ═══════════════════════════════════════════════════════════

    private fun buildPinnedPreview(message: ChatMessage): String {
        if (message.text.isNotBlank()) return message.text
        return when {
            message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
            message.attachments.any { it.type == AttachmentType.FILE } -> "File"
            message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
            message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
            message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message"
            message.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video message"
            message.forwardedMessages.isNotEmpty() -> "Forwarded message"
            message.replyData != null -> "Reply"
            else -> "Pinned message"
        }
    }

    private suspend fun resolvePinnedPreviewText(account: String, messageId: String): String {
        _messages.value.firstOrNull { it.id == messageId }?.let { visibleMessage ->
            return buildPinnedPreview(visibleMessage)
        }

        decryptionCache[messageId]?.takeIf { it.isNotBlank() }?.let { cached ->
            return cached
        }

        val entity = messageDao.getMessageById(account, messageId) ?: return "Pinned message"
        return buildPinnedPreview(entityToChatMessage(entity))
    }

    private suspend fun refreshPinnedMessagePreviews(
            account: String,
            pins: List<com.rosetta.messenger.database.PinnedMessageEntity>
    ) {
        if (pins.isEmpty()) {
            _pinnedMessagePreviews.value = emptyMap()
            return
        }

        val activeIds = pins.map { it.messageId }.toSet()
        val nextPreviews = _pinnedMessagePreviews.value.filterKeys { it in activeIds }.toMutableMap()

        for (pin in pins) {
            val existingPreview = nextPreviews[pin.messageId]
            if (existingPreview != null && existingPreview != "Pinned message") continue
            val preview = resolvePinnedPreviewText(account, pin.messageId)
            nextPreviews[pin.messageId] = preview
        }

        _pinnedMessagePreviews.value = nextPreviews
    }

    suspend fun ensureMessageLoaded(messageId: String): Boolean {
        if (_messages.value.any { it.id == messageId }) return true

        val account = myPublicKey ?: return false
        val opponent = opponentKey ?: return false
        val dialogKey = getDialogKey(account, opponent)

        return withContext(Dispatchers.IO) {
            val entity = messageDao.getMessageById(account, messageId) ?: return@withContext false
            if (entity.dialogKey != dialogKey) return@withContext false

            val hydratedMessage = entityToChatMessage(entity)
            withContext(Dispatchers.Main.immediate) {
                if (_messages.value.none { it.id == messageId }) {
                    _messages.value =
                            sortMessagesAscending((_messages.value + hydratedMessage).distinctBy { it.id })
                }
            }
            updateCacheFromCurrentMessages()
            _pinnedMessagePreviews.update { current ->
                current + (messageId to buildPinnedPreview(hydratedMessage))
            }
            true
        }
    }

    /** 📌 Закрепить сообщение */
    fun pinMessage(messageId: String) {
        viewModelScope.launch(Dispatchers.IO) {
            val account = myPublicKey ?: return@launch
            val opponent = opponentKey ?: return@launch
            val dialogKey = getDialogKey(account, opponent)
            pinnedMessageDao.insertPin(
                    com.rosetta.messenger.database.PinnedMessageEntity(
                            account = account,
                            dialogKey = dialogKey,
                            messageId = messageId
                    )
            )
        }
    }

    /** 📌 Открепить сообщение */
    fun unpinMessage(messageId: String) {
        viewModelScope.launch(Dispatchers.IO) {
            val account = myPublicKey ?: return@launch
            val opponent = opponentKey ?: return@launch
            val dialogKey = getDialogKey(account, opponent)
            pinnedMessageDao.removePin(account, dialogKey, messageId)
        }
    }

    /** 📌 Проверить, закреплено ли сообщение */
    suspend fun isMessagePinned(messageId: String): Boolean {
        val account = myPublicKey ?: return false
        val opponent = opponentKey ?: return false
        val dialogKey = getDialogKey(account, opponent)
        return pinnedMessageDao.isPinned(account, dialogKey, messageId)
    }

    /**
     * 📌 Клик по pinned banner:
     * 1) вернуть ТЕКУЩЕЕ отображаемое сообщение для скролла
     * 2) после этого переключить индекс на следующее (циклически)
     *
     * Так скролл всегда попадает ровно в тот pinned, который видит пользователь в баннере.
     */
    fun navigateToNextPinned(): String? {
        val pins = _pinnedMessages.value
        if (pins.isEmpty()) return null
        val currentIdx = _currentPinnedIndex.value.coerceIn(0, pins.size - 1)
        val currentMessageId = pins[currentIdx].messageId
        val nextIdx = (currentIdx + 1) % pins.size
        _currentPinnedIndex.value = nextIdx
        return currentMessageId
    }

    /** 📌 Открепить все сообщения */
    fun unpinAllMessages() {
        viewModelScope.launch(Dispatchers.IO) {
            val account = myPublicKey ?: return@launch
            val opponent = opponentKey ?: return@launch
            val dialogKey = getDialogKey(account, opponent)
            pinnedMessageDao.unpinAll(account, dialogKey)
        }
    }

    /** 🛑 Отменить исходящую отправку фото во время загрузки */
    fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) {
        protocolGateway.addLog(
                "🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}"
        )
        outgoingImageUploadJobs.remove(messageId)?.cancel(
                CancellationException("User cancelled image upload")
        )
        TransportManager.cancelUpload(attachmentId)
        protocolGateway.resolveOutgoingRetry(messageId)
        deleteMessage(messageId)
    }

    /** 🔥 Удалить сообщение (для ошибки отправки) */
    fun deleteMessage(messageId: String) {
        val account = myPublicKey ?: return
        val opponent = opponentKey ?: return
        val dialogKey = getDialogKey(account, opponent)

        // 1. Mark as pending delete (triggers shrink+fade animation)
        _pendingDeleteIds.value = _pendingDeleteIds.value + messageId

        // 2. After animation completes, remove from list and DB
        viewModelScope.launch {
            kotlinx.coroutines.delay(300) // wait for animation

            val updatedMessages = _messages.value.filter { it.id != messageId }
            _messages.value = updatedMessages
            _pendingDeleteIds.value = _pendingDeleteIds.value - messageId

            updateCacheWithLimit(account, dialogKey, updatedMessages)
            messageRepository.clearDialogCache(opponent)

            withContext(Dispatchers.IO) {
                pinnedMessageDao.removePin(account, dialogKey, messageId)
                messageDao.deleteMessage(account, messageId)
                if (account == opponent) {
                    dialogDao.updateSavedMessagesDialogFromMessages(account)
                } else {
                    dialogDao.updateDialogFromMessages(account, opponent)
                }
            }
        }
    }

    /**
     * Resolve a publicKey to a SearchUser for profile navigation.
     * Tries: local DB → runtime cache → server resolve.
     */
    suspend fun resolveUserForProfile(publicKey: String): SearchUser? {
        if (publicKey.isEmpty()) return null
        if (isGroupDialogKey(publicKey)) return null

        // If it's the current opponent, we already have info
        if (publicKey == opponentKey) {
            return SearchUser(
                title = opponentTitle,
                username = opponentUsername,
                publicKey = publicKey,
                verified = opponentVerified,
                online = 0
            )
        }

        val account = myPublicKey ?: return null

        // 1. Try local DB
        try {
            val dialog = dialogDao.getDialog(account, publicKey)
            if (dialog != null) {
                val t = dialog.opponentTitle.ifEmpty { dialog.opponentUsername }
                if (t.isNotEmpty()) {
                    return SearchUser(
                        title = dialog.opponentTitle,
                        username = dialog.opponentUsername,
                        publicKey = publicKey,
                        verified = dialog.verified,
                        online = 0
                    )
                }
            }
        } catch (_: Exception) {}

        // 2. Try runtime cache
        val cached = protocolGateway.getCachedUserInfo(publicKey)
        if (cached != null) {
            return SearchUser(
                title = cached.title,
                username = cached.username,
                publicKey = publicKey,
                verified = cached.verified,
                online = 0
            )
        }

        // 3. Server resolve
        try {
            val resolved = protocolGateway.resolveUserInfo(publicKey, 3000)
            if (resolved != null) {
                return SearchUser(
                    title = resolved.title,
                    username = resolved.username,
                    publicKey = publicKey,
                    verified = resolved.verified,
                    online = 0
                )
            }
        } catch (_: Exception) {}

        // 4. Fallback: minimal user info
        return SearchUser(
            title = "User",
            username = "",
            publicKey = publicKey,
            verified = 0,
            online = 0
        )
    }

    /**
     * Resolve @username to SearchUser using PacketSearch.
     * Used for clickable mentions inside chat messages.
     */
    suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? {
        val normalized = username.trim().trimStart('@').lowercase(Locale.ROOT)
        if (normalized.isBlank()) return null

        // 1) Local DB first: in private chats this gives instant/stable navigation.
        val account = myPublicKey?.trim().orEmpty()
        if (account.isNotBlank()) {
            val localDialog =
                withContext(Dispatchers.IO) {
                    runCatching { dialogDao.getDialogByUsername(account, normalized) }.getOrNull()
                }
            if (localDialog != null && localDialog.opponentKey.isNotBlank()) {
                return SearchUser(
                    title = localDialog.opponentTitle.ifBlank { normalized },
                    username = localDialog.opponentUsername,
                    publicKey = localDialog.opponentKey,
                    verified = localDialog.verified,
                    online = localDialog.isOnline
                )
            }
        }

        // 2) In-memory protocol cache.
        protocolGateway.getCachedUserByUsername(normalized)?.let { return it }

        // 3) Server search fallback.
        val results = protocolGateway.searchUsers(normalized, timeoutMs)
        if (results.isEmpty()) return null

        return results.firstOrNull {
            it.username.trim().trimStart('@').lowercase(Locale.ROOT) == normalized
        } ?: results.firstOrNull()
    }

    /** 🔥 Повторить отправку сообщения (для ошибки) */
    fun retryMessage(message: ChatMessage) {
        messagesViewModel.retryMessage(message)
    }

    /**
     * 🚀 Оптимизированная отправка сообщения
     * - Optimistic UI (мгновенное отображение)
     * - Шифрование в IO потоке
     * - Сохранение в БД в IO потоке
     * - Поддержка Reply/Forward через attachments (как в React Native)
     */
    private fun encryptAesChachaKey(plainKeyAndNonce: ByteArray, privateKey: String): String {
        return CryptoManager.encryptWithPassword(
                String(plainKeyAndNonce, Charsets.ISO_8859_1),
                privateKey
        )
    }

    fun sendMessage() {
        messagesViewModel.sendMessage()
    }

    /**
     * 📨 Прямая отправка forward-сообщений в указанный чат (без навигации)
     * Используется при мультивыборе чатов для отправки в дополнительные чаты
     */
    fun sendForwardDirectly(
            recipientPublicKey: String,
            forwardMessages: List<ForwardManager.ForwardMessage>
    ) {
        forwardCoordinator.sendForwardDirectly(
                recipientPublicKey = recipientPublicKey,
                forwardMessages = forwardMessages
        )
    }

    /**
     * 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI Фото появляется в чате СРАЗУ,
     * конвертация и отправка происходят в фоне
     *
     * @param imageUri URI изображения
     * @param caption Подпись к изображению
     */
    fun sendImageFromUri(imageUri: android.net.Uri, caption: String = "") {
        attachmentsViewModel.sendImageFromUri(imageUri = imageUri, caption = caption)
    }

    /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
    internal fun updateOptimisticImageMessage(
            messageId: String,
            base64: String,
            blurhash: String,
            width: Int,
            height: Int
    ) {
        attachmentsCoordinator.updateOptimisticImageMessage(
                messageId = messageId,
                base64 = base64,
                blurhash = blurhash,
                width = width,
                height = height
        )
    }

    /** 📤 Внутренняя функция отправки изображения (уже с готовым base64) */
    internal suspend fun sendImageMessageInternal(
            messageId: String,
            imageBase64: String,
            blurhash: String,
            caption: String,
            width: Int,
            height: Int,
            timestamp: Long,
            recipient: String,
            sender: String,
            privateKey: String
    ) {
        attachmentsCoordinator.sendImageMessageInternal(
                messageId = messageId,
                imageBase64 = imageBase64,
                blurhash = blurhash,
                caption = caption,
                width = width,
                height = height,
                timestamp = timestamp,
                recipient = recipient,
                sender = sender,
                privateKey = privateKey
        )
    }

    /**
     * 📸 Отправка сообщения с изображением
     * @param imageBase64 Base64 изображения
     * @param blurhash Blurhash preview изображения
     * @param caption Подпись к изображению (опционально)
     * @param width Ширина изображения (для правильного отображения)
     * @param height Высота изображения (для правильного отображения)
     */
    fun sendImageMessage(
            imageBase64: String,
            blurhash: String,
            caption: String = "",
            width: Int = 0,
            height: Int = 0
    ) {
        attachmentsViewModel.sendImageMessage(
                imageBase64 = imageBase64,
                blurhash = blurhash,
                caption = caption,
                width = width,
                height = height
        )
    }

    /**
     * 🖼️ Отправка группы изображений как коллаж (как в Telegram)
     * @param images Список пар (base64, blurhash) для каждого изображения
     * @param caption Подпись к группе изображений (опционально)
     */
    data class ImageData(
            val base64: String,
            val blurhash: String,
            val width: Int = 0,
            val height: Int = 0
    )

    internal suspend fun sendVideoCircleMessageInternal(
            messageId: String,
            attachmentId: String,
            timestamp: Long,
            videoHex: String,
            preview: String,
            width: Int,
            height: Int,
            recipient: String,
            sender: String,
            privateKey: String
    ) {
        attachmentsCoordinator.sendVideoCircleMessageInternal(
                messageId = messageId,
                attachmentId = attachmentId,
                timestamp = timestamp,
                videoHex = videoHex,
                preview = preview,
                width = width,
                height = height,
                recipient = recipient,
                sender = sender,
                privateKey = privateKey
        )
    }

    /**
     * 🎙️ Отправка голосового сообщения.
     * blob хранится как HEX строка opus/webm байт (desktop parity).
     * preview формат: "<durationSec>::<wave1,wave2,...>"
     */
    fun sendVoiceMessage(
            voiceHex: String,
            durationSec: Int,
            waves: List<Float>
    ) {
        voiceRecordingViewModel.sendVoiceMessage(
                voiceHex = voiceHex,
                durationSec = durationSec,
                waves = waves
        )
    }

    /**
     * Сохранить диалог в базу данных 🔥 Используем updateDialogFromMessages для пересчета счетчиков
     * из messages 📁 SAVED MESSAGES: Использует специальный метод для saved messages
     */
    private suspend fun saveDialog(
            lastMessage: String,
            timestamp: Long,
            accountPublicKey: String,
            accountPrivateKey: String,
            opponentPublicKey: String
    ) {
        val account = accountPublicKey.trim().takeIf { it.isNotEmpty() } ?: return
        val opponent = opponentPublicKey.trim().takeIf { it.isNotEmpty() } ?: return

        try {
            // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики
            // напрямую из таблицы messages, как в Архиве!
            // 📁 Используем специальный метод для saved messages
            if (opponent == account) {
                dialogDao.updateSavedMessagesDialogFromMessages(account)
            } else {
                dialogDao.updateDialogFromMessages(account, opponent)
                dialogDao.markIHaveSent(account, opponent)
            }

            ensureDialogVisibleAfterSend(
                    account = account,
                    opponent = opponent,
                    fallbackMessage = lastMessage,
                    timestamp = timestamp,
                    accountPrivateKey = accountPrivateKey
            )

            if (isGroupDialogKey(opponent)) {
                val groupId = normalizeGroupId(opponent)
                val group = groupDao.getGroup(account, groupId)
                if (group != null) {
                    dialogDao.updateOpponentDisplayName(
                            account,
                            opponent,
                            group.title,
                            group.description
                    )
                    return
                }
            }

            // 🔥 FIX: updateDialogFromMessages создаёт новый диалог с пустым title/username
            // когда диалога ещё не было. Обновляем метаданные из уже известных данных.
            if (opponent != account && opponentTitle.isNotEmpty()) {
                dialogDao.updateOpponentDisplayName(
                        account, opponent, opponentTitle, opponentUsername
                )
            }
        } catch (e: Exception) {}
    }

    private suspend fun ensureDialogVisibleAfterSend(
            account: String,
            opponent: String,
            fallbackMessage: String,
            timestamp: Long,
            accountPrivateKey: String
    ) {
        val existing = dialogDao.getDialog(account, opponent)
        val needsFallback =
                existing == null ||
                        existing.hasContent != 1 ||
                        (opponent != account && existing.iHaveSent != 1) ||
                        existing.lastMessageTimestamp < timestamp
        if (!needsFallback) return

        val privateKey = accountPrivateKey.trim().takeIf { it.isNotEmpty() }
        val dialogKey = getDialogKey(account, opponent)
        val latestMessage = runCatching { dialogDao.getLastMessageByDialogKey(account, dialogKey) }.getOrNull()
        val encryptedFallback =
                if (fallbackMessage.isNotBlank() && !privateKey.isNullOrBlank()) {
                    runCatching { CryptoManager.encryptWithPassword(fallbackMessage, privateKey) }
                            .getOrNull()
                            .orEmpty()
                } else {
                    ""
                }

        val fallbackLastMessage =
                latestMessage?.plainMessage
                        ?.takeIf { it.isNotBlank() }
                        ?: existing?.lastMessage?.takeIf { it.isNotBlank() }
                        ?: encryptedFallback
        val fallbackTimestamp =
                maxOf(
                        timestamp,
                        latestMessage?.timestamp ?: 0L,
                        existing?.lastMessageTimestamp ?: 0L
                )
        val fallbackAttachmentType =
                latestMessage?.primaryAttachmentType ?: existing?.lastMessageAttachmentType ?: -1
        val fallbackAttachments =
                latestMessage?.attachments?.takeIf { it.isNotBlank() }
                        ?: existing?.lastMessageAttachments
                        ?: "[]"
        val fallbackFromMe = latestMessage?.fromMe ?: 1
        val fallbackDelivered =
                if (fallbackFromMe == 1) {
                    latestMessage?.delivered ?: existing?.lastMessageDelivered ?: 0
                } else {
                    0
                }
        val fallbackRead =
                if (fallbackFromMe == 1) {
                    latestMessage?.read ?: existing?.lastMessageRead ?: 0
                } else {
                    0
                }

        dialogDao.insertDialog(
                com.rosetta.messenger.database.DialogEntity(
                        id = existing?.id ?: 0L,
                        account = account,
                        opponentKey = opponent,
                        opponentTitle =
                                when {
                                    opponent == account ->
                                            existing?.opponentTitle?.ifBlank { "Saved Messages" }
                                                    ?: "Saved Messages"
                                    opponentTitle.isNotBlank() -> opponentTitle
                                    else -> existing?.opponentTitle.orEmpty()
                                },
                        opponentUsername =
                                when {
                                    opponent == account -> existing?.opponentUsername.orEmpty()
                                    opponentUsername.isNotBlank() -> opponentUsername
                                    else -> existing?.opponentUsername.orEmpty()
                                },
                        lastMessage = fallbackLastMessage,
                        lastMessageTimestamp = fallbackTimestamp,
                        unreadCount = existing?.unreadCount ?: 0,
                        isOnline = existing?.isOnline ?: 0,
                        lastSeen = existing?.lastSeen ?: 0L,
                        verified = existing?.verified ?: 0,
                        iHaveSent = 1,
                        hasContent = 1,
                        lastMessageAttachmentType = fallbackAttachmentType,
                        lastSenderKey = latestMessage?.fromPublicKey ?: existing?.lastSenderKey.orEmpty(),
                        lastMessageFromMe = fallbackFromMe,
                        lastMessageDelivered = fallbackDelivered,
                        lastMessageRead = fallbackRead,
                        lastMessageAttachments = fallbackAttachments
                )
        )

        if (opponent != account) {
            dialogDao.markIHaveSent(account, opponent)
        }

        protocolGateway.addLog(
                "🛠️ DIALOG_FALLBACK upserted opponent=${opponent.take(12)} ts=$fallbackTimestamp hasContent=1"
        )
    }

    /**
     * Обновить диалог при входящем сообщении 📁 SAVED MESSAGES: Использует специальный метод для
     * saved messages
     */
    private suspend fun updateDialog(
            opponentKey: String,
            lastMessage: String,
            timestamp: Long,
            incrementUnread: Boolean
    ) {
        val account = myPublicKey ?: return

        try {
            // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики
            // напрямую из таблицы messages, как в Архиве!
            // Это гарантирует что unread_count всегда соответствует реальному количеству
            // непрочитанных
            // 📁 Используем специальный метод для saved messages
            if (opponentKey == account) {
                dialogDao.updateSavedMessagesDialogFromMessages(account)
            } else {
                dialogDao.updateDialogFromMessages(account, opponentKey)
            }

            if (isGroupDialogKey(opponentKey)) {
                val groupId = normalizeGroupId(opponentKey)
                val group = groupDao.getGroup(account, groupId)
                if (group != null) {
                    dialogDao.updateOpponentDisplayName(
                            account,
                            opponentKey,
                            group.title,
                            group.description
                    )
                    return
                }
            }

            // 🔥 FIX: Сохраняем title/username после пересчёта счётчиков
            if (opponentKey != account && opponentTitle.isNotEmpty()) {
                dialogDao.updateOpponentDisplayName(
                        account, opponentKey, opponentTitle, opponentUsername
                )
            }
        } catch (e: Exception) {}
    }

    /**
     * Сохранить сообщение в базу данных 🔒 Безопасность: plainMessage НЕ сохраняется - только
     * зашифрованный content + chachaKey
     * @param text
     * - расшифрованный текст (используется только для логов и обновления диалога)
     * @param opponentPublicKey
     * - публичный ключ собеседника (используется вместо глобального opponentKey)
     */
    private suspend fun saveMessageToDatabase(
            messageId: String,
            text: String,
            encryptedContent: String,
            encryptedKey: String,
            timestamp: Long,
            isFromMe: Boolean,
            delivered: Int = 0,
            attachmentsJson: String = "[]",
            accountPublicKey: String,
            accountPrivateKey: String,
            opponentPublicKey: String
    ) {
        val account = accountPublicKey.trim().takeIf { it.isNotEmpty() } ?: return
        val opponent = opponentPublicKey.trim().takeIf { it.isNotEmpty() } ?: return
        val privateKey = accountPrivateKey.trim().takeIf { it.isNotEmpty() } ?: return

        try {
            val dialogKey = getDialogKey(account, opponent)

            // 🔒 Проверяем messageId - если пустой, генерируем новый
            val finalMessageId =
                    if (messageId.isEmpty()) {
                        val generated = UUID.randomUUID().toString().replace("-", "").take(32)
                        generated
                    } else {
                        messageId
                    }

            // 🔒 Шифруем plainMessage с использованием приватного ключа
            val encryptedPlainMessage = CryptoManager.encryptWithPassword(text, privateKey)

            // ✅ Проверяем существует ли сообщение - ИСПОЛЬЗУЕМ результат!
            val exists = messageDao.messageExists(account, finalMessageId)
            if (exists) {
                // Сообщение уже есть в БД - не дублируем
                return
            }

            val entity =
                    MessageEntity(
                            account = account,
                            fromPublicKey = if (isFromMe) account else opponent,
                            toPublicKey = if (isFromMe) opponent else account,
                            content = encryptedContent,
                            timestamp = timestamp,
                            chachaKey = encryptedKey,
                            read = if (isFromMe && opponent == account) 1 else 0,
                            fromMe = if (isFromMe) 1 else 0,
                            delivered = delivered,
                            messageId = finalMessageId,
                            plainMessage =
                                    encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
                            // БД
                            attachments = attachmentsJson,
                            primaryAttachmentType =
                                    resolvePrimaryAttachmentTypeFromJson(attachmentsJson),
                            replyToMessageId = null,
                            dialogKey = dialogKey
                    )

            val insertedId = messageDao.insertMessage(entity)
            searchIndexDao.upsert(
                    listOf(
                            com.rosetta.messenger.database.MessageSearchIndexEntity(
                                    account = account,
                                    messageId = finalMessageId,
                                    dialogKey = dialogKey,
                                    opponentKey = opponent.trim(),
                                    timestamp = timestamp,
                                    fromMe = if (isFromMe) 1 else 0,
                                    plainText = text,
                                    plainTextNormalized = text.lowercase(Locale.ROOT)
                            )
                    )
            )
        } catch (e: Exception) {}
    }

    private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int {
        if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1
        return try {
            val array = parseAttachmentsJsonArray(attachmentsJson) ?: return -1
            if (array.length() == 0) return -1
            val first = array.optJSONObject(0) ?: return -1
            val parsedType = parseAttachmentType(first)
            if (parsedType != AttachmentType.UNKNOWN) {
                parsedType.value
            } else if (array.length() == 1 &&
                    isLikelyCallAttachmentPreview(first.optString("preview", ""))
            ) {
                AttachmentType.CALL.value
            } else {
                -1
            }
        } catch (_: Throwable) {
            -1
        }
    }

    private fun showTypingIndicator(senderPublicKey: String? = null, typingUsersCount: Int = 1) {
        val currentDialog = opponentKey?.trim().orEmpty()
        val normalizedSender = senderPublicKey?.trim().orEmpty()
        val isGroupTyping = isGroupDialogKey(currentDialog) && normalizedSender.isNotBlank()
        val normalizedTypingUsersCount = typingUsersCount.coerceAtLeast(1)

        if (isGroupTyping) {
            typingSenderPublicKey = normalizedSender
            _typingDisplayPublicKey.value = normalizedSender
            this.typingUsersCount = normalizedTypingUsersCount
            val knownName = resolveKnownGroupSenderName(normalizedSender)
            val initialName =
                    if (isUsableSenderName(knownName, normalizedSender)) {
                        knownName
                    } else {
                        shortPublicKey(normalizedSender)
                    }
            _typingDisplayName.value =
                    buildGroupTypingDisplayName(initialName, normalizedTypingUsersCount)

            requestGroupSenderNameIfNeeded(normalizedSender)

            typingNameResolveJob?.cancel()
            typingNameResolveJob =
                viewModelScope.launch(Dispatchers.IO) {
                    val resolvedName = resolveGroupSenderName(normalizedSender)
                    withContext(Dispatchers.Main.immediate) {
                        if (_opponentTyping.value &&
                            typingSenderPublicKey.equals(normalizedSender, ignoreCase = true)
                        ) {
                            _typingDisplayName.value =
                                    buildGroupTypingDisplayName(
                                            resolvedName,
                                            this@ChatViewModel.typingUsersCount
                                    )
                        }
                    }
                }
        } else {
            typingNameResolveJob?.cancel()
            typingSenderPublicKey = null
            _typingDisplayName.value = ""
            _typingDisplayPublicKey.value = ""
            this.typingUsersCount = 1
        }

        _opponentTyping.value = true
    }

    private fun clearTypingIndicatorState() {
        typingNameResolveJob?.cancel()
        _opponentTyping.value = false
        _typingDisplayName.value = ""
        _typingDisplayPublicKey.value = ""
        typingSenderPublicKey = null
        typingUsersCount = 1
    }

    /**
     * 📝 Отправить индикатор "печатает..." С throttling чтобы не спамить сервер 📁 Для Saved
     * Messages - не отправляем (нельзя печатать самому себе)
     */
    fun sendTypingIndicator() {
        typingViewModel.sendTypingIndicator()
    }

    /**
     * 👁️ Отправить read receipt собеседнику.
     * Desktop parity: отправляем и для групп (toPublicKey = group dialog key).
     * Не отправляем только для saved messages (нельзя слать самому себе).
     */
    private fun sendReadReceiptToOpponent() {
        // 🔥 Не отправляем read receipt если диалог не активен (как в архиве)
        if (!isDialogActive) {
            return
        }

        val opponent = opponentKey ?: return
        val sender = myPublicKey ?: return

        // 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
        if (opponent == sender) {
            return
        }

        val privateKey = myPrivateKey ?: return

        // 🔥 Проверяем timestamp ДО отправки — если не изменился, не шлём повторно
        val lastIncoming = latestIncomingMessage(_messages.value)
        if (lastIncoming == null) return
        val incomingTs = lastIncoming.timestamp.time
        if (incomingTs <= lastReadMessageTimestamp) return

        viewModelScope.launch(Dispatchers.IO) {
            try {
                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
                sendReadReceiptUseCase(
                        SendReadReceiptCommand(
                                privateKeyHash = privateKeyHash,
                                fromPublicKey = sender,
                                toPublicKey = opponent
                        )
                )
                // ✅ Обновляем timestamp ПОСЛЕ успешной отправки
                lastReadMessageTimestamp = incomingTs
                MessageLogger.logReadReceiptSent(opponent)
            } catch (e: Exception) {
                MessageLogger.logReadReceiptFailed(opponent, e)
                // 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную)
                try {
                    kotlinx.coroutines.delay(2000)
                    sendReadReceiptUseCase(
                            SendReadReceiptCommand(
                                    privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey),
                                    fromPublicKey = sender,
                                    toPublicKey = opponent
                            )
                    )
                    lastReadMessageTimestamp = incomingTs
                    MessageLogger.logReadReceiptSent(opponent, retry = true)
                } catch (retryEx: Exception) {
                    MessageLogger.logReadReceiptFailed(opponent, retryEx, retry = true)
                }
            }
        }
    }

    /**
     * 👁️ Публичный метод для отправки read receipt (вызывается из ChatDetailScreen) Теперь
     * работает как в архиве - при изменении списка сообщений
     */
    fun markVisibleMessagesAsRead() {
        // 🔥 Не читаем если диалог не активен
        if (!isDialogActive) {
            return
        }

        val opponent = opponentKey ?: return
        val account = myPublicKey ?: return

        // Находим последнее входящее сообщение
        val lastIncoming = latestIncomingMessage(_messages.value)
        if (lastIncoming == null) return

        // Если timestamp не изменился - не отправляем повторно
        if (lastIncoming.timestamp.time <= lastReadMessageTimestamp) return

        // Отмечаем в БД и пересчитываем счетчики
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val dialogKey = getDialogKey(account, opponent)
                messageDao.markDialogAsRead(account, dialogKey)
                // 🔥 Пересчитываем счетчики из messages
                // 📁 Используем специальный метод для saved messages
                if (opponent == account) {
                    dialogDao.updateSavedMessagesDialogFromMessages(account)
                } else {
                    dialogDao.updateDialogFromMessages(account, opponent)
                }
            } catch (e: Exception) {}
        }

        // Отправляем read receipt
        sendReadReceiptToOpponent()
    }

    /**
     * 🟢 Подписаться на онлайн статус собеседника 📁 Для Saved Messages - не подписываемся 🔥
     * Проверяем флаг чтобы не подписываться повторно
     */
    fun subscribeToOnlineStatus() {
        // 🔥 Если уже подписаны - не подписываемся повторно!
        if (subscribedToOnlineStatus) return

        val opponent = opponentKey ?: return
        val privateKey = myPrivateKey ?: return
        val account = myPublicKey ?: return

        // 📁 Для Saved Messages - не нужно подписываться на свой собственный статус
        if (account == opponent || isGroupDialogKey(opponent)) {
            return
        }

        // 🔥 Устанавливаем флаг ДО отправки пакета
        subscribedToOnlineStatus = true

        viewModelScope.launch(Dispatchers.IO) {
            try {
                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)

                val packet =
                        PacketOnlineSubscribe().apply {
                            this.privateKey = privateKeyHash
                            addPublicKey(opponent)
                        }

                protocolGateway.send(packet)
            } catch (e: Exception) {}
        }
    }

    fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending

    /** 🗑️ Очистка истории чата Удаляет все сообщения между текущим пользователем и собеседником */
    fun clearChatHistory() {
        val account = myPublicKey ?: return
        val opponent = opponentKey ?: return

        viewModelScope.launch(Dispatchers.IO) {
            try {
                // Удаляем все сообщения из БД
                if (isGroupDialogKey(opponent)) {
                    val dialogKey = getDialogKey(account, opponent)
                    messageDao.deleteDialog(account, dialogKey)
                } else {
                    messageDao.deleteMessagesBetweenUsers(account, account, opponent)
                }

                // Очищаем кэш
                val dialogKey = getDialogKey(account, opponent)
                dialogMessagesCache.remove(cacheKey(account, dialogKey))

                // Очищаем UI
                withContext(Dispatchers.Main) { _messages.value = emptyList() }

                // Обновляем диалог (последнее сообщение и счётчики)
                val isSavedMessages = (opponent == account)
                if (isSavedMessages) {
                    dialogDao.updateSavedMessagesDialogFromMessages(account)
                } else {
                    dialogDao.updateDialogFromMessages(account, opponent)
                }
            } catch (e: Exception) {}
        }
    }

    /** Вспомогательная функция для конвертации base64 в Bitmap */
    private fun base64ToBitmap(base64: String): Bitmap? {
        return try {
            val cleanBase64 =
                    if (base64.contains(",")) {
                        base64.substringAfter(",")
                    } else {
                        base64
                    }
            val bytes = Base64.decode(cleanBase64, Base64.DEFAULT)
            BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
        } catch (e: Exception) {
            null
        }
    }

    override fun onCleared() {
        super.onCleared()
        isCleared = true
        typingSnapshotJob?.cancel()
        opponentOnlineStatusJob?.cancel()
        typingNameResolveJob?.cancel()
        draftSaveJob?.cancel()
        pinnedCollectionJob?.cancel()
        messagesCoordinator.onCleared()
        outgoingImageUploadJobs.values.forEach { it.cancel() }
        outgoingImageUploadJobs.clear()

        lastReadMessageTimestamp = 0L
        subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
        opponentKey = null
    }
}
