package com.rosetta.messenger.ui.chats

import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.lifecycle.AndroidViewModel
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.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.*
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.MessageLogger
import com.rosetta.messenger.utils.MessageThrottleManager
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

/**
 * ViewModel для экрана чата - оптимизированная версия 🚀 Особенности:
 * - Dispatchers.IO для всех тяжёлых операций
 * - Пагинация сообщений
 * - Chunked decryption (расшифровка пачками)
 * - Кэширование расшифрованных сообщений
 * - Flow для реактивных обновлений без блокировки UI
 */
class ChatViewModel(application: Application) : AndroidViewModel(application) {

    companion object {
        private const val TAG = "ChatViewModel"
        private const val PAGE_SIZE = 30
        private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
        private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
        private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
        private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
        private val chatMessageAscComparator =
                compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
        private val chatMessageDescComparator =
                compareByDescending<ChatMessage> { it.timestamp.time }.thenByDescending { it.id }

        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(application)
    private val dialogDao = database.dialogDao()
    private val messageDao = database.messageDao()
    private val groupDao = database.groupDao()
    private val pinnedMessageDao = database.pinnedMessageDao()

    // MessageRepository для подписки на события новых сообщений
    private val messageRepository =
            com.rosetta.messenger.data.MessageRepository.getInstance(application)

    // 🔥 Кэш расшифрованных сообщений (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()

    /**
     * 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
    @OptIn(FlowPreview::class)
    val messagesWithDates: StateFlow<List<Pair<ChatMessage, Boolean>>> =
            _messages
                    .debounce(16) // coalesce rapid updates (1 frame)
                    .mapLatest { rawMessages ->
                        withContext(Dispatchers.Default) {
                            val unique = rawMessages.distinctBy { it.id }
                            val sorted = unique.sortedWith(chatMessageDescComparator)
                            val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size)
                            var prevDateStr: String? = null
                            for (i in sorted.indices) {
                                val msg = sorted[i]
                                val dateStr = _dateFmt.format(msg.timestamp)
                                val nextMsg = sorted.getOrNull(i + 1)
                                val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
                                val showDate = nextDateStr == null || nextDateStr != dateStr
                                result.add(msg to showDate)
                                prevDateStr = dateStr
                            }
                            result as List<Pair<ChatMessage, Boolean>>
                        }
                    }
                    .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 var typingTimeoutJob: kotlinx.coroutines.Job? = null

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

    private val _opponentLastSeen = MutableStateFlow(0L)
    val opponentLastSeen: StateFlow<Long> = _opponentLastSeen.asStateFlow()

    // 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() // Для показа превью
    )
    private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
    val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()

    private val _isForwardMode = MutableStateFlow(false)
    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 var isSending = false
    // 🔥 Throttling перенесён в глобальный MessageThrottleManager

    // Job для отмены загрузки при смене диалога
    private var loadingJob: Job? = null

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

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

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

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

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

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

    // 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
    // ВАЖНО: Должны быть определены ДО init блока!
    private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet ->
        val typingPacket = packet as PacketTyping
        val currentDialog = opponentKey?.trim().orEmpty()
        val currentAccount = myPublicKey?.trim().orEmpty()
        if (currentDialog.isBlank() || currentAccount.isBlank()) {
            return@typingPacketHandler
        }

        val fromPublicKey = typingPacket.fromPublicKey.trim()
        val toPublicKey = typingPacket.toPublicKey.trim()
        if (fromPublicKey.isBlank() || toPublicKey.isBlank()) {
            return@typingPacketHandler
        }
        if (fromPublicKey.equals(currentAccount, ignoreCase = true)) {
            return@typingPacketHandler
        }

        val shouldShowTyping =
                if (isGroupDialogKey(currentDialog)) {
                    normalizeGroupId(toPublicKey).equals(normalizeGroupId(currentDialog), ignoreCase = true)
                } else {
                    fromPublicKey.equals(currentDialog, ignoreCase = true) &&
                            toPublicKey.equals(currentAccount, ignoreCase = true)
                }

        if (shouldShowTyping) {
            showTypingIndicator()
        }
    }

    private val onlinePacketHandler: (Packet) -> Unit = { packet ->
        val onlinePacket = packet as PacketOnlineState
        onlinePacket.publicKeysState.forEach { item ->
            if (item.publicKey == opponentKey) {
                _opponentOnline.value = item.state == OnlineState.ONLINE
            }
        }
    }

    init {
        setupPacketListeners()
        setupNewMessageListener()
        setupDeliveryStatusListener()
    }

    // 🔥 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) {
                    if (!isDialogActive) return@collect
                    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 setupPacketListeners() {
        // ✅ Обработчики 0x06, 0x07, 0x08 обрабатываются в ProtocolManager → MessageRepository
        // ChatViewModel получает обновления через deliveryStatusEvents SharedFlow

        // Typing - нужен здесь для UI текущего чата
        ProtocolManager.waitPacket(0x0B, typingPacketHandler)

        // 🟢 Онлайн статус - нужен здесь для UI текущего чата
        ProtocolManager.waitPacket(0x05, onlinePacketHandler)
    }

    // ✅ 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) {
        _messages.value =
                _messages.value.map { msg ->
                    if (msg.id != messageId) return@map msg

                    // Keep read status monotonic: late DELIVERED must not downgrade READ.
                    val mergedStatus =
                        when (status) {
                            MessageStatus.DELIVERED ->
                                if (msg.status == MessageStatus.READ) MessageStatus.READ
                                else MessageStatus.DELIVERED
                            else -> status
                        }
                    if (mergedStatus != msg.status) msg.copy(status = mergedStatus) else msg
                }

        // 🔥 Также обновляем кэш!
        updateCacheFromCurrentMessages()
    }

    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) {
        ProtocolManager.addLog("📸 IMG ${shortPhotoId(messageId)} | $message")
    }

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

    /** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */
    private fun updateMessageAttachments(messageId: String, localUri: String?) {
        _messages.value =
                _messages.value.map { msg ->
                    if (msg.id == messageId) {
                        val updatedAttachments =
                                msg.attachments.map { att -> att.copy(localUri = localUri ?: "") }
                        msg.copy(attachments = updatedAttachments)
                    } else msg
                }

        // 🔥 Также обновляем кэш!
        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 {
            messageDao.updateDeliveryStatusAndAttachments(
                    account,
                    messageId,
                    delivered,
                    attachmentsJson
            )
        } catch (e: Exception) {}
    }

    /** Установить ключи пользователя */
    fun setUserKeys(publicKey: String, privateKey: String) {
        if (myPublicKey != publicKey) {
            // 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 = publicKey
        myPrivateKey = privateKey
    }

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

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

        // Отменяем предыдущую загрузку
        loadingJob?.cancel()

        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
            _isLoading.value = false
        } else {
            // Нет кэша — показываем skeleton вместо пустого Lottie
            _isLoading.value = true
            _messages.value = emptyList()
        }
        _opponentOnline.value = false
        _opponentTyping.value = false
        typingTimeoutJob?.cancel()
        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
                        )
                    }
            _isForwardMode.value = true
        } else {
            // Сбрасываем forward state если нет forward сообщений
            _replyMessages.value = emptyList()
            _isForwardMode.value = false
        }

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

        // 📌 Подписываемся на 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)
            }
        }

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

    /**
     * 🔥 Закрыть диалог (вызывается когда пользователь выходит из чата) Как
     * setCurrentDialogPublicKeyView("") в архиве
     */
    fun closeDialog() {
        isDialogActive = false
        typingTimeoutJob?.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)

        // 📁 Проверяем является ли это 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()
                                    _isLoading.value = 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
                                _isLoading.value = 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()
                                _isLoading.value = false
                            }
                            isLoadingMessages = false
                            return@launch
                        }

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

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

                        hasMoreMessages = entities.size >= PAGE_SIZE
                        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
                            _isLoading.value = 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) { _isLoading.value = 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 = PAGE_SIZE, offset = 0)
                    } else {
                        messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, 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 =
                                when (entity.delivered) {
                                    0 -> MessageStatus.SENDING
                                    1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
                                    2 -> MessageStatus.ERROR
                                    3 -> MessageStatus.READ
                                    else -> MessageStatus.SENT
                                }

                        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 >= PAGE_SIZE
            // 🔥 ИСПРАВЛЕНИЕ: НЕ сбрасываем 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 = PAGE_SIZE,
                                    offset = currentOffset
                            )
                        } else {
                            messageDao.getMessages(
                                    account,
                                    dialogKey,
                                    limit = PAGE_SIZE,
                                    offset = currentOffset
                            )
                        }

                hasMoreMessages = entities.size >= 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 = parseAllAttachments(entity.attachments)

        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)
        }

        return ChatMessage(
                id = entity.messageId,
                text = displayText,
                isOutgoing = entity.fromMe == 1,
                timestamp = Date(entity.timestamp),
                status =
                        when (entity.delivered) {
                            0 -> MessageStatus.SENDING
                            1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
                            2 -> MessageStatus.ERROR
                            3 -> MessageStatus.READ
                            else -> MessageStatus.SENT
                        },
                replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
                forwardedMessages = forwardedMessages,
                attachments = parsedAttachments,
                chachaKey = entity.chachaKey,
                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 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 = ProtocolManager.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 = ProtocolManager.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) {
                    _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 == '='
            }
        }
    }

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

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

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

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

                var blob = attachment.optString("blob", "")
                val attachmentId = attachment.optString("id", "")
                val attachmentType = AttachmentType.fromInt(type)

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

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

            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 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 = JSONArray(attachmentsJson)

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

                // MESSAGES = 1 (цитата)
                if (type == 1) {

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

                    if (dataJson.isEmpty()) {
                        dataJson = attachment.optString("preview", "")
                    }

                    if (dataJson.isEmpty()) {
                        continue
                    }

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

                    if (dataJson.contains(":") && dataJson.split(":").size == 2) {
                        val privateKey = myPrivateKey
                        var decryptionSuccess = false

                        // 🔥 Способ 0: Группа — blob шифруется ключом группы
                        if (groupPassword != null) {
                            try {
                                val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
                                if (decrypted != null) {
                                    dataJson = decrypted
                                    decryptionSuccess = true
                                }
                            } catch (_: Exception) {}
                        }

                        // 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
                        // сообщений)
                        if (privateKey != null) {
                            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) {}
                        }

                        if (!decryptionSuccess) {
                            continue
                        }
                    } else {}

                    val messagesArray =
                            try {
                                JSONArray(dataJson)
                            } catch (e: Exception) {
                                continue
                            }

                    if (messagesArray.length() > 0) {
                        val account = myPublicKey ?: return null
                        val dialogKey = getDialogKey(account, opponentKey ?: "")

                        // Check if this is a forwarded set or a regular reply
                        // Desktop doesn't set "forwarded" flag, but sends multiple messages in the array
                        val firstMsg = messagesArray.getJSONObject(0)
                        val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1

                        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 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", "")
                                            if (attId.isNotEmpty()) {
                                                fwdAttachments.add(MessageAttachment(
                                                    id = attId, type = attType, preview = attPreview,
                                                    blob = attBlob, width = attWidth, height = attHeight,
                                                    localUri = attLocalUri
                                                ))
                                            }
                                        }
                                    }
                                } 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 ProtocolManager cache (previously resolved)
                                        val cachedName = dbName ?: ProtocolManager.getCachedUserName(fwdPublicKey)
                                        
                                        // 3. Server resolve via PacketSearch (like desktop useUserInformation)
                                        val serverName = if (cachedName == null) {
                                            try {
                                                ProtocolManager.resolveUserName(fwdPublicKey, 3000)
                                            } catch (_: Exception) { null }
                                        } else null
                                        
                                        cachedName ?: serverName ?: "User"
                                    } else "User"
                                }

                                forwardedList.add(ReplyData(
                                    messageId = fwdMessageId,
                                    senderName = senderDisplayName,
                                    text = fwdText,
                                    isFromMe = fwdIsFromMe,
                                    isForwarded = true,
                                    forwardedFromName = senderDisplayName,
                                    attachments = fwdAttachments,
                                    senderPublicKey = fwdPublicKey.ifEmpty { if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "" },
                                    recipientPrivateKey = myPrivateKey ?: ""
                                ))
                            }
                            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", "")

                        // 🔥 Detect forward: explicit flag OR publicKey belongs to a third party
                        // Desktop doesn't send "forwarded" flag, but if publicKey differs from
                        // both myPublicKey and opponentKey — it's a forwarded message from someone else
                        val isFromThirdParty = replyPublicKey.isNotEmpty() &&
                                replyPublicKey != myPublicKey &&
                                replyPublicKey != opponentKey
                        val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty

                        // 📸 Парсим 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", "")

                                    if (attId.isNotEmpty()) {
                                        replyAttachmentsFromJson.add(
                                                MessageAttachment(
                                                        id = attId,
                                                        type = attType,
                                                        preview = attPreview,
                                                        blob = attBlob,
                                                        width = attWidth,
                                                        height = attHeight,
                                                        localUri = attLocalUri
                                                )
                                        )
                                    }
                                }
                            }
                        } 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 из JSON если есть, иначе загружаем из БД
                        val originalAttachments =
                                if (replyAttachmentsFromJson.isNotEmpty()) {
                                    // Используем attachments из JSON reply
                                    replyAttachmentsFromJson
                                } else {
                                    // Fallback: загружаем из БД
                                    try {
                                        val originalMessage =
                                                messageDao.findMessageByContent(
                                                        account = account,
                                                        dialogKey = dialogKey,
                                                        fromPublicKey = replyPublicKey,
                                                        timestampFrom = replyTimestamp - 5000,
                                                        timestampTo = replyTimestamp + 5000
                                                )
                                        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 ProtocolManager cache
                                val cachedName = dbName ?: ProtocolManager.getCachedUserName(replyPublicKey)
                                // 3. Server resolve via PacketSearch (like desktop useUserInformation)
                                val serverName = if (cachedName == null) {
                                    try {
                                        ProtocolManager.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 = replyText,
                                        isFromMe = isReplyFromMe,
                                        isForwarded = isForwarded,
                                        forwardedFromName = forwardFromDisplay,
                                        attachments = originalAttachments,
                                        senderPublicKey = replyPublicKey.ifEmpty {
                                                if (isReplyFromMe) myPublicKey ?: ""
                                                else opponentKey ?: ""
                                        },
                                        recipientPrivateKey = myPrivateKey ?: ""
                                )

                        // 🔥 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) {
                            return ParsedReplyResult(
                                replyData = result,
                                forwardedMessages = listOf(result)
                            )
                        }
                        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
    }

    private data class OutgoingEncryptionContext(
            val encryptedContent: String,
            val encryptedKey: String,
            val aesChachaKey: String,
            val plainKeyAndNonce: ByteArray?,
            val attachmentPassword: String,
            val isGroup: Boolean
    )

    private 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,
                    attachmentPassword = String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
                    isGroup = false
            )
        }
    }

    private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
        return if (context.isGroup) {
            CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
        } else {
            val plainKeyAndNonce =
                    context.plainKeyAndNonce
                            ?: throw IllegalStateException("Missing key+nonce for direct message")
            MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
        }
    }

    /** Обновить текст ввода */
    fun updateInputText(text: String) {
        _inputText.value = text
        // 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
        opponentKey?.let { key ->
            com.rosetta.messenger.data.DraftManager.saveDraft(key, 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
                            }
                    ReplyMessage(
                            messageId = msg.id,
                            text = msg.text,
                            timestamp = msg.timestamp.time,
                            isOutgoing = msg.isOutgoing,
                            // В группах senderPublicKey содержит автора сообщения, а opponent = ключ группы.
                            // Для desktop parity в reply JSON нужен именно ключ автора.
                            publicKey = resolvedPublicKey,
                            senderName = msg.senderName,
                            attachments =
                                    msg.attachments
                                            .filter { it.type != AttachmentType.MESSAGES }
                    )
                }
        _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
                            }
                    ReplyMessage(
                            messageId = msg.id,
                            text = msg.text,
                            timestamp = msg.timestamp.time,
                            isOutgoing = msg.isOutgoing,
                            publicKey = resolvedPublicKey,
                            senderName = msg.senderName,
                            attachments =
                                    msg.attachments
                                            .filter { it.type != AttachmentType.MESSAGES }
                    )
                }
        _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.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 deleteMessage(messageId: String) {
        val account = myPublicKey ?: return
        val opponent = opponentKey ?: return
        val dialogKey = getDialogKey(account, opponent)

        // Удаляем из UI сразу на main
        val updatedMessages = _messages.value.filter { it.id != messageId }
        _messages.value = updatedMessages
        // Синхронизируем глобальный кэш диалога, иначе удалённые сообщения могут вернуться
        // при повторном открытии чата из stale cache.
        updateCacheWithLimit(account, dialogKey, updatedMessages)
        messageRepository.clearDialogCache(opponent)

        // Удаляем из БД в IO + удаляем pin если был
        viewModelScope.launch(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 → ProtocolManager 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 ProtocolManager cache
        val cached = ProtocolManager.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 = ProtocolManager.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.
        ProtocolManager.getCachedUserByUsername(normalized)?.let { return it }

        // 3) Server search fallback.
        val results = ProtocolManager.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) {
        // Удаляем старое сообщение
        deleteMessage(message.id)

        // Устанавливаем текст в инпут и отправляем
        _inputText.value = message.text

        // Отправляем с небольшой задержкой
        viewModelScope.launch {
            delay(100)
            sendMessage()
        }
    }

    /**
     * 🚀 Оптимизированная отправка сообщения
     * - 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() {
        val text = _inputText.value.trim()
        val recipient = opponentKey
        val sender = myPublicKey
        val privateKey = myPrivateKey
        val replyMsgs = _replyMessages.value
        val replyMsgsToSend = replyMsgs.toList()
        val isForward = _isForwardMode.value

        // Разрешаем отправку пустого текста если есть reply/forward
        if (text.isEmpty() && replyMsgs.isEmpty()) {
            return
        }
        if (recipient == null) {
            return
        }
        if (sender == null || privateKey == null) {
            return
        }
        if (isSending) {
            return
        }

        // 🔥 Глобальный throttle - защита от спама сообщениями (app-wide)
        val dialogKey = "$sender:$recipient"
        if (!MessageThrottleManager.canSendWithContent(dialogKey, text.hashCode())) {
            return
        }

        isSending = true

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()

        // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
        // Используется для обычного reply (не forward).
        val replyData: ReplyData? =
                if (replyMsgsToSend.isNotEmpty()) {
                    val firstReply = replyMsgsToSend.first()
                    // 🖼️ Получаем attachments из текущих сообщений для превью
                    // Fallback на firstReply.attachments для forward из другого чата
                    val replyAttachments =
                            _messages.value.find { it.id == firstReply.messageId }?.attachments
                                    ?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
                    // 🔥 Use actual senderName from ForwardMessage (preserves real author)
                    val firstReplySenderName = if (firstReply.isOutgoing) "You"
                            else firstReply.senderName.ifEmpty {
                                opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
                            }
                    ReplyData(
                            messageId = firstReply.messageId,
                            senderName = firstReplySenderName,
                            text = firstReply.text,
                            isFromMe = firstReply.isOutgoing,
                            isForwarded = isForward,
                            forwardedFromName =
                                    if (isForward) firstReplySenderName else "",
                            attachments = replyAttachments,
                            senderPublicKey = firstReply.publicKey.ifEmpty {
                                    if (firstReply.isOutgoing) myPublicKey ?: ""
                                    else opponentKey ?: ""
                            },
                            recipientPrivateKey = myPrivateKey ?: ""
                    )
                } else null

        // 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble,
        // а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение.
        val optimisticForwardedMessages: List<ReplyData> =
                if (isForward && replyMsgsToSend.isNotEmpty()) {
                    replyMsgsToSend.map { msg ->
                        val senderDisplayName =
                                if (msg.isOutgoing) "You"
                                else msg.senderName.ifEmpty {
                                    opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
                                }
                        val resolvedAttachments =
                                _messages.value.find { it.id == msg.messageId }?.attachments
                                        ?: msg.attachments.filter { it.type != AttachmentType.MESSAGES }
                        ReplyData(
                                messageId = msg.messageId,
                                senderName = senderDisplayName,
                                text = msg.text,
                                isFromMe = msg.isOutgoing,
                                isForwarded = true,
                                forwardedFromName = senderDisplayName,
                                attachments = resolvedAttachments,
                                senderPublicKey = msg.publicKey.ifEmpty {
                                    if (msg.isOutgoing) myPublicKey ?: "" else opponentKey ?: ""
                                },
                                recipientPrivateKey = myPrivateKey ?: ""
                        )
                    }
                } else {
                    emptyList()
                }

        // Сохраняем режим forward для отправки ПЕРЕД очисткой
        val isForwardToSend = isForward

        // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble
        val optimisticMessage =
                ChatMessage(
                        id = messageId,
                        text = text, // Только основной текст, без prefix
                        isOutgoing = true,
                        timestamp = Date(timestamp),
                        status = MessageStatus.SENDING,
                        replyData = if (isForwardToSend) null else replyData,
                        forwardedMessages = optimisticForwardedMessages
                )

        // � Безопасное добавление с проверкой дубликатов
        addMessageSafely(optimisticMessage)
        _inputText.value = ""

        // � Очищаем черновик после отправки
        opponentKey?.let { com.rosetta.messenger.data.DraftManager.clearDraft(it) }

        // �🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации
        clearReplyMessages()

        // Кэшируем текст
        decryptionCache[messageId] = text

        // 2. 🔥 Шифрование и отправка в IO потоке
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val encryptionContext =
                        buildEncryptionContext(
                                plaintext = text,
                                recipient = recipient,
                                privateKey = privateKey
                        ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
                val encryptedContent = encryptionContext.encryptedContent
                val encryptedKey = encryptionContext.encryptedKey
                val aesChachaKey = encryptionContext.aesChachaKey

                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)

                // 🔥 Формируем attachments с reply (как в React Native)
                val messageAttachments = mutableListOf<MessageAttachment>()
                var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)

                // 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
                // Map: originalAttId → (newAttId, newPreview) — для подстановки в reply JSON
                val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
                if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
                    val context = getApplication<Application>()
                    val isSaved = (sender == recipient)
                    var fwdIdx = 0

                    for (msg in replyMsgsToSend) {
                        for (att in msg.attachments) {
                            if (att.type == AttachmentType.IMAGE) {
                                try {
                                    val imageBlob = AttachmentFileManager.readAttachment(
                                            context = context,
                                            attachmentId = att.id,
                                            publicKey = msg.publicKey,
                                            privateKey = privateKey
                                    )
                                    if (imageBlob != null) {
                                        val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
                                        val newAttId = "fwd_${timestamp}_${fwdIdx++}"

                                        var uploadTag = ""
                                        if (!isSaved) {
                                            uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
                                        }

                                        val blurhash = att.preview.substringAfter("::", "")
                                        val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash

                                        forwardedAttMap[att.id] = Pair(newAttId, newPreview)

                                        // Сохраняем локально с новым ID
                                        // publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
                                        AttachmentFileManager.saveAttachment(
                                                context = context,
                                                blob = imageBlob,
                                                attachmentId = newAttId,
                                                publicKey = msg.publicKey,
                                                privateKey = privateKey
                                        )
                                    }
                                } catch (e: Exception) { }
                            }
                        }
                    }
                }

                if (replyMsgsToSend.isNotEmpty()) {

                    // Формируем JSON массив с цитируемыми сообщениями (как в Desktop)
                    val replyJsonArray = JSONArray()
                    replyMsgsToSend.forEach { msg ->
                        val attachmentsArray = JSONArray()
                        msg.attachments.forEach { att ->
                            // Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
                            val fwdInfo = forwardedAttMap[att.id]
                            val attId = fwdInfo?.first ?: att.id
                            val attPreview = fwdInfo?.second ?: att.preview

                            attachmentsArray.put(
                                    JSONObject().apply {
                                        put("id", attId)
                                        put("type", att.type.value)
                                        put("preview", attPreview)
                                        put("width", att.width)
                                        put("height", att.height)
                                        put(
                                                "blob",
                                                if (att.type == AttachmentType.MESSAGES) att.blob
                                                else ""
                                        )
                                    }
                            )
                        }

                        val replyJson =
                                JSONObject().apply {
                                    put("message_id", msg.messageId)
                                    put("publicKey", msg.publicKey)
                                    put("message", msg.text)
                                    put("timestamp", msg.timestamp)
                                    put("attachments", attachmentsArray)
                                    if (isForwardToSend) {
                                        put("forwarded", true)
                                        put("senderName", msg.senderName)
                                    }
                                }
                        replyJsonArray.put(replyJson)
                    }

                    val replyBlobPlaintext = replyJsonArray.toString()

                    val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)

                    replyBlobForDatabase =
                            CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)

                    val replyAttachmentId = "reply_${timestamp}"
                    messageAttachments.add(
                            MessageAttachment(
                                    id = replyAttachmentId,
                                    blob = encryptedReplyBlob,
                                    type = AttachmentType.MESSAGES,
                                    preview = ""
                            )
                    )
                }

                val packet =
                        PacketMessage().apply {
                            fromPublicKey = sender
                            toPublicKey = recipient
                            content = encryptedContent
                            chachaKey = encryptedKey
                            this.aesChachaKey = aesChachaKey
                            this.timestamp = timestamp
                            this.privateKey = privateKeyHash
                            this.messageId = messageId
                            attachments = messageAttachments
                        }

                // 🔥 DEBUG: Log packet before sending
                packet.attachments.forEachIndexed { idx, att -> }

                // 📁 Для Saved Messages - НЕ отправляем пакет на сервер
                // Только сохраняем локально
                val isSavedMessages = (sender == recipient)
                if (!isSavedMessages) {
                    // Отправляем пакет только для обычных диалогов
                    ProtocolManager.send(packet)
                }

                // 3. 🎯 UI обновление в Main потоке
                // Для обычных диалогов статус остаётся SENDING до PacketDelivery(messageId).
                withContext(Dispatchers.Main) {
                    if (isSavedMessages) {
                        updateMessageStatus(messageId, MessageStatus.SENT)
                    }
                }

                // 4. 💾 Сохранение в БД с attachments
                // ⚠️ НЕ сохраняем blob для IMAGE/FILE - слишком большие (SQLite CursorWindow 2MB
                // limit)
                // Только MESSAGES (reply) сохраняем - они небольшие
                val attachmentsJson =
                        if (messageAttachments.isNotEmpty()) {
                            JSONArray()
                                    .apply {
                                        messageAttachments.forEach { att ->
                                            put(
                                                    JSONObject().apply {
                                                        put("id", att.id)
                                                        put("type", att.type.value)
                                                        put("preview", att.preview)
                                                        put("width", att.width)
                                                        put("height", att.height)
                                                        // Только для MESSAGES сохраняем blob (reply
                                                        // data небольшие)
                                                        // Для IMAGE/FILE - пустой blob
                                                        val blobToSave =
                                                                when (att.type) {
                                                                    AttachmentType.MESSAGES ->
                                                                            replyBlobForDatabase
                                                                                    ?: ""
                                                                    else -> "" // IMAGE, FILE - не
                                                                // сохраняем blob
                                                                }
                                                        put("blob", blobToSave)
                                                    }
                                            )
                                        }
                                    }
                                    .toString()
                        } else "[]"

                saveMessageToDatabase(
                        messageId = messageId,
                        text = text,
                        encryptedContent = encryptedContent,
                        encryptedKey =
                                if (encryptionContext.isGroup) {
                                    buildStoredGroupKey(
                                            encryptionContext.attachmentPassword,
                                            privateKey
                                    )
                                } else {
                                    encryptedKey
                                },
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered =
                                if (isSavedMessages) 1
                                else 0, // 📁 Saved Messages: сразу DELIVERED (1), иначе SENDING (0)
                        attachmentsJson = attachmentsJson
                )

                saveDialog(text, timestamp)
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    updateMessageStatus(messageId, MessageStatus.ERROR)
                }
                // Update error status in DB + dialog
                updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
                saveDialog(text, timestamp)
            } finally {
                isSending = false
            }
        }
    }

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

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()
        val isCurrentDialogTarget = recipientPublicKey == opponentKey

        viewModelScope.launch(Dispatchers.IO) {
            try {
                val context = getApplication<Application>()
                val isSavedMessages = (sender == recipientPublicKey)
                val db = RosettaDatabase.getDatabase(context)
                val dialogDao = db.dialogDao()

                suspend fun refreshTargetDialog() {
                    if (isSavedMessages) {
                        dialogDao.updateSavedMessagesDialogFromMessages(sender)
                    } else {
                        dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
                    }
                }

                // Шифрование (пустой текст для forward)
                val encryptionContext =
                        buildEncryptionContext(
                                plaintext = "",
                                recipient = recipientPublicKey,
                                privateKey = privateKey
                        ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
                val encryptedContent = encryptionContext.encryptedContent
                val encryptedKey = encryptionContext.encryptedKey
                val aesChachaKey = encryptionContext.aesChachaKey
                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
                val replyAttachmentId = "reply_${timestamp}"

                fun buildForwardReplyJson(
                        forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
                        includeLocalUri: Boolean
                ): JSONArray {
                    val replyJsonArray = JSONArray()
                    forwardMessages.forEach { fm ->
                        val attachmentsArray = JSONArray()
                        fm.attachments.forEach { att ->
                            val fwdInfo = forwardedIdMap[att.id]
                            val attId = fwdInfo?.first ?: att.id
                            val attPreview = fwdInfo?.second ?: att.preview

                            attachmentsArray.put(
                                    JSONObject().apply {
                                        put("id", attId)
                                        put("type", att.type.value)
                                        put("preview", attPreview)
                                        put("width", att.width)
                                        put("height", att.height)
                                        put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
                                        if (includeLocalUri && att.localUri.isNotEmpty()) {
                                            put("localUri", att.localUri)
                                        }
                                    }
                            )
                        }
                        replyJsonArray.put(
                                JSONObject().apply {
                                    put("message_id", fm.messageId)
                                    put("publicKey", fm.senderPublicKey)
                                    put("message", fm.text)
                                    put("timestamp", fm.timestamp)
                                    put("attachments", attachmentsArray)
                                    put("forwarded", true)
                                    put("senderName", fm.senderName)
                                }
                        )
                    }
                    return replyJsonArray
                }

                // 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге
                if (isCurrentDialogTarget) {
                    val optimisticForwardedMessages =
                            forwardMessages.map { fm ->
                                val senderDisplayName =
                                        fm.senderName.ifEmpty {
                                            if (fm.senderPublicKey == sender) "You" else "User"
                                        }
                                ReplyData(
                                        messageId = fm.messageId,
                                        senderName = senderDisplayName,
                                        text = fm.text,
                                        isFromMe = fm.senderPublicKey == sender,
                                        isForwarded = true,
                                        forwardedFromName = senderDisplayName,
                                        attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES },
                                        senderPublicKey = fm.senderPublicKey,
                                        recipientPrivateKey = privateKey
                                )
                            }
                    withContext(Dispatchers.Main) {
                        addMessageSafely(
                                ChatMessage(
                                        id = messageId,
                                        text = "",
                                        isOutgoing = true,
                                        timestamp = Date(timestamp),
                                        status = MessageStatus.SENDING,
                                        forwardedMessages = optimisticForwardedMessages
                                )
                        )
                    }
                }

                // 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу
                val optimisticReplyBlobPlaintext =
                        buildForwardReplyJson(includeLocalUri = true).toString()
                val optimisticReplyBlobForDatabase =
                        CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)

                val optimisticAttachmentsJson =
                        JSONArray()
                                .apply {
                                    put(
                                            JSONObject().apply {
                                                put("id", replyAttachmentId)
                                                put("type", AttachmentType.MESSAGES.value)
                                                put("preview", "")
                                                put("width", 0)
                                                put("height", 0)
                                                put("blob", optimisticReplyBlobForDatabase)
                                            }
                                    )
                                }
                                .toString()

                saveMessageToDatabase(
                        messageId = messageId,
                        text = "",
                        encryptedContent = encryptedContent,
                        encryptedKey =
                                if (encryptionContext.isGroup) {
                                    buildStoredGroupKey(
                                            encryptionContext.attachmentPassword,
                                            privateKey
                                    )
                                } else {
                                    encryptedKey
                                },
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered = if (isSavedMessages) 1 else 0,
                        attachmentsJson = optimisticAttachmentsJson,
                        opponentPublicKey = recipientPublicKey
                )
                refreshTargetDialog()

                if (isSavedMessages && isCurrentDialogTarget) {
                    withContext(Dispatchers.Main) {
                        updateMessageStatus(messageId, MessageStatus.SENT)
                    }
                }

                // 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
                // Map: originalAttId → (newAttId, newPreview)
                val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
                var fwdIdx = 0
                for (fm in forwardMessages) {
                    for (att in fm.attachments) {
                        if (att.type == AttachmentType.IMAGE) {
                            try {
                                val imageBlob = AttachmentFileManager.readAttachment(
                                        context = context,
                                        attachmentId = att.id,
                                        publicKey = fm.senderPublicKey,
                                        privateKey = privateKey
                                )
                                if (imageBlob != null) {
                                    val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
                                    val newAttId = "fwd_${timestamp}_${fwdIdx++}"

                                    var uploadTag = ""
                                    if (!isSavedMessages) {
                                        uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
                                    }

                                    val blurhash = att.preview.substringAfter("::", "")
                                    val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash

                                    forwardedAttMap[att.id] = Pair(newAttId, newPreview)

                                    // Сохраняем локально с новым ID
                                    // publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
                                    AttachmentFileManager.saveAttachment(
                                            context = context,
                                            blob = imageBlob,
                                            attachmentId = newAttId,
                                            publicKey = fm.senderPublicKey,
                                            privateKey = privateKey
                                    )
                                }
                            } catch (e: Exception) { }
                        }
                    }
                }

                val replyBlobPlaintext =
                        buildForwardReplyJson(
                                        forwardedIdMap = forwardedAttMap,
                                        includeLocalUri = false
                                )
                                .toString()
                val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
                val replyBlobForDatabase =
                        CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)

                val finalMessageAttachments =
                        listOf(
                                MessageAttachment(
                                        id = replyAttachmentId,
                                        blob = encryptedReplyBlob,
                                        type = AttachmentType.MESSAGES,
                                        preview = ""
                                )
                        )

                // Отправляем пакет
                val packet = PacketMessage().apply {
                    fromPublicKey = sender
                    toPublicKey = recipientPublicKey
                    content = encryptedContent
                    chachaKey = encryptedKey
                    this.aesChachaKey = aesChachaKey
                    this.timestamp = timestamp
                    this.privateKey = privateKeyHash
                    this.messageId = messageId
                    attachments = finalMessageAttachments
                }
                if (!isSavedMessages) {
                    ProtocolManager.send(packet)
                }

                val finalAttachmentsJson =
                        JSONArray()
                                .apply {
                                    finalMessageAttachments.forEach { att ->
                                        put(
                                                JSONObject().apply {
                                                    put("id", att.id)
                                                    put("type", att.type.value)
                                                    put("preview", att.preview)
                                                    put("width", att.width)
                                                    put("height", att.height)
                                                    put(
                                                            "blob",
                                                            when (att.type) {
                                                                AttachmentType.MESSAGES ->
                                                                        replyBlobForDatabase
                                                                else -> ""
                                                            }
                                                    )
                                                }
                                        )
                                    }
                                }
                                .toString()

                updateMessageStatusAndAttachmentsInDb(
                        messageId = messageId,
                        delivered = if (isSavedMessages) 1 else 0,
                        attachmentsJson = finalAttachmentsJson
                )

                if (isCurrentDialogTarget) {
                    withContext(Dispatchers.Main) {
                        if (isSavedMessages) {
                            updateMessageStatus(messageId, MessageStatus.SENT)
                        }
                    }
                }
                refreshTargetDialog()
            } catch (e: Exception) {
                if (isCurrentDialogTarget) {
                    withContext(Dispatchers.Main) {
                        updateMessageStatus(messageId, MessageStatus.ERROR)
                    }
                }
                updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
            }
        }
    }

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

        if (recipient == null || sender == null || privateKey == null) {
            ProtocolManager.addLog(
                    "❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
            )
            return
        }

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()
        val text = caption.trim()
        val attachmentId = "img_$timestamp"

        logPhotoPipeline(
                messageId,
                "start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${shortPhotoId(attachmentId, 12)}"
        )

        // 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла)
        // Это предотвращает "расширение" пузырька при первом показе
        val (imageWidth, imageHeight) =
                com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
        logPhotoPipeline(messageId, "dimensions: ${imageWidth}x$imageHeight")

        // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ
        // Используем URI напрямую для отображения (без конвертации в base64)
        val optimisticMessage =
                ChatMessage(
                        id = messageId,
                        text = text,
                        isOutgoing = true,
                        timestamp = Date(timestamp),
                        status = MessageStatus.SENDING,
                        attachments =
                                listOf(
                                        MessageAttachment(
                                                id = attachmentId,
                                                blob = "", // Пока пустой, обновим после конвертации
                                                type = AttachmentType.IMAGE,
                                                preview =
                                                        "", // Пока пустой, обновим после генерации
                                                width = imageWidth, // 🔥 Используем реальные
                                                // размеры сразу!
                                                height = imageHeight, // 🔥 Используем реальные
                                                // размеры сразу!
                                                localUri = imageUri.toString() // 🔥 Используем
                                                // localUri для
                                                // мгновенного показа
                                                )
                                )
                )
        addMessageSafely(optimisticMessage)
        _inputText.value = ""
        logPhotoPipeline(messageId, "optimistic UI added")

        // 2. 🔄 В фоне, независимо от жизненного цикла экрана:
        // сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
        backgroundUploadScope.launch {
            try {
                logPhotoPipeline(messageId, "persist optimistic message in DB")
                val optimisticAttachmentsJson =
                        JSONArray()
                                .apply {
                                    put(
                                            JSONObject().apply {
                                                put("id", attachmentId)
                                                put("type", AttachmentType.IMAGE.value)
                                                put("preview", "")
                                                put("blob", "")
                                                put("width", imageWidth)
                                                put("height", imageHeight)
                                                put("localUri", imageUri.toString())
                                            }
                                    )
                                }
                                .toString()

                saveMessageToDatabase(
                        messageId = messageId,
                        text = text,
                        encryptedContent = "",
                        encryptedKey = "",
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered = 0,
                        attachmentsJson = optimisticAttachmentsJson,
                        opponentPublicKey = recipient
                )

                // Обновляем dialogs сразу, чтобы в списке чатов мгновенно показать "Photo" + часы
                // даже если пользователь вышел из экрана чата во время загрузки.
                saveDialog(
                        lastMessage = if (text.isNotEmpty()) text else "photo",
                        timestamp = timestamp,
                        opponentPublicKey = recipient
                )
                logPhotoPipeline(messageId, "optimistic dialog updated")
            } catch (_: Exception) {
                logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
            }

            try {
                val convertStartedAt = System.currentTimeMillis()
                val (width, height) =
                        com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)

                val imageBase64 =
                        com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
                if (imageBase64 == null) {
                    logPhotoPipeline(messageId, "base64 conversion returned null")
                    if (!isCleared) {
                        withContext(Dispatchers.Main) {
                            updateMessageStatus(messageId, MessageStatus.ERROR)
                        }
                    }
                    return@launch
                }
                logPhotoPipeline(
                        messageId,
                        "base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms"
                )

                val blurhash =
                        com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
                logPhotoPipeline(messageId, "blurhash ready: len=${blurhash.length}")

                if (!isCleared) {
                    withContext(Dispatchers.Main) {
                        updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
                    }
                    logPhotoPipeline(messageId, "optimistic payload updated in UI")
                }

                sendImageMessageInternal(
                        messageId = messageId,
                        imageBase64 = imageBase64,
                        blurhash = blurhash,
                        caption = text,
                        width = width,
                        height = height,
                        timestamp = timestamp,
                        recipient = recipient,
                        sender = sender,
                        privateKey = privateKey
                )
                logPhotoPipeline(messageId, "pipeline completed")
            } catch (e: Exception) {
                logPhotoPipelineError(messageId, "prepare+convert", e)
                if (!isCleared) {
                    withContext(Dispatchers.Main) {
                        updateMessageStatus(messageId, MessageStatus.ERROR)
                    }
                }
            }
        }
    }

    /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
    private fun updateOptimisticImageMessage(
            messageId: String,
            base64: String,
            blurhash: String,
            width: Int,
            height: Int
    ) {
        val currentMessages = _messages.value.toMutableList()
        val index = currentMessages.indexOfFirst { it.id == messageId }
        if (index != -1) {
            val message = currentMessages[index]
            val updatedAttachments =
                    message.attachments.map { att ->
                        if (att.type == AttachmentType.IMAGE) {
                            att.copy(
                                    preview = blurhash,
                                    blob = base64,
                                    width = width,
                                    height = height
                            )
                        } else att
                    }
            currentMessages[index] = message.copy(attachments = updatedAttachments)
            _messages.value = currentMessages
        }
    }

    /** 📤 Внутренняя функция отправки изображения (уже с готовым base64) */
    private suspend fun sendImageMessageInternal(
            messageId: String,
            imageBase64: String,
            blurhash: String,
            caption: String,
            width: Int,
            height: Int,
            timestamp: Long,
            recipient: String,
            sender: String,
            privateKey: String
    ) {
        try {
            val context = getApplication<Application>()
            val pipelineStartedAt = System.currentTimeMillis()
            logPhotoPipeline(
                    messageId,
                    "internal send start: base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}, captionLen=${caption.length}"
            )

            // Шифрование текста
            val encryptStartedAt = System.currentTimeMillis()
            val encryptionContext =
                    buildEncryptionContext(
                            plaintext = caption,
                            recipient = recipient,
                            privateKey = privateKey
                    ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
            val encryptedContent = encryptionContext.encryptedContent
            val encryptedKey = encryptionContext.encryptedKey
            val aesChachaKey = encryptionContext.aesChachaKey
            logPhotoPipeline(
                    messageId,
                    "text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms"
            )

            val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)

            // 🚀 Шифруем изображение с ChaCha ключом для Transport Server
            val blobEncryptStartedAt = System.currentTimeMillis()
            val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
            logPhotoPipeline(
                    messageId,
                    "blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
            )

            val attachmentId = "img_$timestamp"
            logPhotoPipeline(
                    messageId,
                    "attachment prepared: id=${shortPhotoId(attachmentId, 12)}, size=${width}x$height"
            )

            // 📤 Загружаем на Transport Server
            val isSavedMessages = (sender == recipient)
            var uploadTag = ""

            if (!isSavedMessages) {
                logPhotoPipeline(messageId, "upload start: attachment=${shortPhotoId(attachmentId, 12)}")
                uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
                logPhotoPipeline(
                        messageId,
                        "upload done: tag=${shortPhotoId(uploadTag, 12)}"
                )
            } else {
                logPhotoPipeline(messageId, "saved-messages mode: upload skipped")
            }

            // Preview содержит tag::blurhash
            val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash

            val imageAttachment =
                    MessageAttachment(
                            id = attachmentId,
                            blob = "",
                            type = AttachmentType.IMAGE,
                            preview = previewWithTag,
                            width = width,
                            height = height
                    )

            val packet =
                    PacketMessage().apply {
                        fromPublicKey = sender
                        toPublicKey = recipient
                        content = encryptedContent
                        chachaKey = encryptedKey
                        this.aesChachaKey = aesChachaKey
                        this.timestamp = timestamp
                        this.privateKey = privateKeyHash
                        this.messageId = messageId
                        attachments = listOf(imageAttachment)
                    }

            // Отправляем пакет
            if (!isSavedMessages) {
                ProtocolManager.send(packet)
                logPhotoPipeline(messageId, "packet sent to protocol")
            } else {
                logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
            }

            // 💾 Сохраняем изображение в файл локально
            val savedLocally =
                    AttachmentFileManager.saveAttachment(
                    context = context,
                    blob = imageBase64,
                    attachmentId = attachmentId,
                    publicKey = sender,
                    privateKey = privateKey
            )
            logPhotoPipeline(messageId, "local file cache saved=$savedLocally")

            // Сохраняем в БД
            val attachmentsJson =
                    JSONArray()
                            .apply {
                                put(
                                        JSONObject().apply {
                                            put("id", attachmentId)
                                            put("type", AttachmentType.IMAGE.value)
                                            put("preview", previewWithTag)
                                            put("blob", "")
                                            put("width", width)
                                            put("height", height)
                                        }
                                )
                            }
                            .toString()

            // 🔄 Обновляем статус на SENT и очищаем localUri в attachments
            // (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем)
            val finalAttachmentsJson = attachmentsJson // Уже без localUri

            val deliveryStatus = if (isSavedMessages) 1 else 0
            updateMessageStatusAndAttachmentsInDb(messageId, deliveryStatus, finalAttachmentsJson)
            logPhotoPipeline(messageId, "db status+attachments updated")

            withContext(Dispatchers.Main) {
                if (isSavedMessages) {
                    updateMessageStatus(messageId, MessageStatus.SENT)
                }
                // Также очищаем localUri в UI
                updateMessageAttachments(messageId, null)
            }
            logPhotoPipeline(
                    messageId,
                    if (isSavedMessages) "ui status switched to SENT"
                    else "ui status kept at SENDING until delivery ACK"
            )

                saveDialog(
                        lastMessage = if (caption.isNotEmpty()) caption else "photo",
                        timestamp = timestamp,
                        opponentPublicKey = recipient
                )
            logPhotoPipeline(
                    messageId,
                    "dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
            )
        } catch (e: Exception) {
            logPhotoPipelineError(messageId, "internal-send", e)
            withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
        }
    }

    /**
     * 📸 Отправка сообщения с изображением
     * @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
    ) {
        val recipient = opponentKey
        val sender = myPublicKey
        val privateKey = myPrivateKey

        if (recipient == null || sender == null || privateKey == null) {
            return
        }
        if (isSending) {
            return
        }

        isSending = true

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()
        val text = caption.trim()

        // 1. 🚀 Optimistic UI - показываем сообщение с placeholder
        val optimisticMessage =
                ChatMessage(
                        id = messageId,
                        text = text,
                        isOutgoing = true,
                        timestamp = Date(timestamp),
                        status = MessageStatus.SENDING,
                        attachments =
                                listOf(
                                        MessageAttachment(
                                                id = "img_$timestamp",
                                                type = AttachmentType.IMAGE,
                                                preview = blurhash,
                                                blob = imageBase64, // Для локального отображения
                                                width = width,
                                                height = height
                                        )
                                )
                )
        // 🔥 Безопасное добавление с проверкой дубликатов
        addMessageSafely(optimisticMessage)
        _inputText.value = ""

        backgroundUploadScope.launch {
            try {
                // Шифрование текста
                val encryptionContext =
                        buildEncryptionContext(
                                plaintext = text,
                                recipient = recipient,
                                privateKey = privateKey
                        ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
                val encryptedContent = encryptionContext.encryptedContent
                val encryptedKey = encryptionContext.encryptedKey
                val aesChachaKey = encryptionContext.aesChachaKey

                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)

                // 🚀 Шифруем изображение с ChaCha ключом для Transport Server
                val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)

                val attachmentId = "img_$timestamp"

                // 📤 Загружаем на Transport Server (как в desktop)
                // НЕ для Saved Messages - там не нужно загружать
                val isSavedMessages = (sender == recipient)
                var uploadTag = ""

                if (!isSavedMessages) {
                    uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
                }

                // Preview содержит tag::blurhash (как в desktop)
                val previewWithTag =
                        if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash

                val imageAttachment =
                        MessageAttachment(
                                id = attachmentId,
                                blob = "", // 🔥 Пустой blob - файл на Transport Server!
                                type = AttachmentType.IMAGE,
                                preview = previewWithTag,
                                width = width,
                                height = height
                        )

                val packet =
                        PacketMessage().apply {
                            fromPublicKey = sender
                            toPublicKey = recipient
                            content = encryptedContent
                            chachaKey = encryptedKey
                            this.aesChachaKey = aesChachaKey
                            this.timestamp = timestamp
                            this.privateKey = privateKeyHash
                            this.messageId = messageId
                            attachments = listOf(imageAttachment)
                        }

                // Отправляем пакет (без blob!)
                if (!isSavedMessages) {
                    ProtocolManager.send(packet)
                }

                // 💾 Сохраняем изображение в файл локально (как в desktop)
                AttachmentFileManager.saveAttachment(
                        context = getApplication(),
                        blob = imageBase64,
                        attachmentId = attachmentId,
                        publicKey = sender,
                        privateKey = privateKey
                )

                // ⚠️ НЕ сохраняем blob в БД - он слишком большой (SQLite CursorWindow 2MB limit)
                // Изображение хранится в файловой системе
                val attachmentsJson =
                        JSONArray()
                                .apply {
                                    put(
                                            JSONObject().apply {
                                                put("id", attachmentId)
                                                put("type", AttachmentType.IMAGE.value)
                                                put("preview", previewWithTag)
                                                put("blob", "") // Пустой blob - не сохраняем в БД!
                                                put("width", width)
                                                put("height", height)
                                            }
                                    )
                                }
                                .toString()

                saveMessageToDatabase(
                        messageId = messageId,
                        text = text,
                        encryptedContent = encryptedContent,
                        encryptedKey =
                                if (encryptionContext.isGroup) {
                                    buildStoredGroupKey(
                                            encryptionContext.attachmentPassword,
                                            privateKey
                                    )
                                } else {
                                    encryptedKey
                                },
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных
                        attachmentsJson = attachmentsJson,
                        opponentPublicKey = recipient
                )

                // Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
                withContext(Dispatchers.Main) {
                    if (isSavedMessages) {
                        updateMessageStatus(messageId, MessageStatus.SENT)
                    }
                }

                saveDialog(
                        lastMessage = if (text.isNotEmpty()) text else "photo",
                        timestamp = timestamp,
                        opponentPublicKey = recipient
                )
            } catch (e: Exception) {
                withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
            } finally {
                isSending = false
            }
        }
    }

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

    /**
     * 🖼️ Отправка группы изображений из URI как одного media-group сообщения.
     * Нужна для корректного коллажа у получателя (а не отдельных фото-сообщений).
     */
    fun sendImageGroupFromUris(imageUris: List<android.net.Uri>, caption: String = "") {
        if (imageUris.isEmpty()) return

        if (imageUris.size == 1) {
            sendImageFromUri(imageUris.first(), caption)
            return
        }

        val recipient = opponentKey
        val sender = myPublicKey
        val privateKey = myPrivateKey
        val context = getApplication<Application>()
        val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)

        if (recipient == null || sender == null || privateKey == null) {
            ProtocolManager.addLog(
                    "❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
            )
            return
        }
        if (isSending) {
            ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress")
            return
        }

        isSending = true

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()
        val text = caption.trim()
        val attachmentIds = imageUris.indices.map { index -> "img_${timestamp}_$index" }

        val optimisticAttachments =
                imageUris.mapIndexed { index, uri ->
                    MessageAttachment(
                            id = attachmentIds[index],
                            blob = "",
                            type = AttachmentType.IMAGE,
                            preview = "",
                            width = 0,
                            height = 0,
                            localUri = uri.toString()
                    )
                }

        addMessageSafely(
                ChatMessage(
                        id = messageId,
                        text = text,
                        isOutgoing = true,
                        timestamp = Date(timestamp),
                        status = MessageStatus.SENDING,
                        attachments = optimisticAttachments
                )
        )
        _inputText.value = ""

        ProtocolManager.addLog(
                "📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
        )

        backgroundUploadScope.launch {
            try {
                val optimisticAttachmentsJson =
                        JSONArray().apply {
                            imageUris.forEachIndexed { index, uri ->
                                put(
                                        JSONObject().apply {
                                            put("id", attachmentIds[index])
                                            put("type", AttachmentType.IMAGE.value)
                                            put("preview", "")
                                            put("blob", "")
                                            put("width", 0)
                                            put("height", 0)
                                            put("localUri", uri.toString())
                                        }
                                )
                            }
                        }.toString()

                saveMessageToDatabase(
                        messageId = messageId,
                        text = text,
                        encryptedContent = "",
                        encryptedKey = "",
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered = 0,
                        attachmentsJson = optimisticAttachmentsJson,
                        opponentPublicKey = recipient
                )

                saveDialog(
                        lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
                        timestamp = timestamp,
                        opponentPublicKey = recipient
                )
            } catch (_: Exception) {
                ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)")
            }

            val preparedImages =
                    imageUris.mapIndexed { index, uri ->
                        val (width, height) =
                                com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
                                        context,
                                        uri
                                )
                        val imageBase64 =
                                com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(
                                        context,
                                        uri
                                )
                                        ?: run {
                                                ProtocolManager.addLog(
                                                        "❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
                                                )
                                                throw IllegalStateException(
                                                        "group item#$index base64 conversion failed"
                                                )
                                        }
                        val blurhash =
                                com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
                                        context,
                                        uri
                                )
                        ProtocolManager.addLog(
                                "📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
                        )
                        index to
                                ImageData(
                                        base64 = imageBase64,
                                        blurhash = blurhash,
                                        width = width,
                                        height = height
                                )
                    }

            if (preparedImages.isEmpty()) {
                    ProtocolManager.addLog(
                            "❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
                    )
                    updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
                    withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
                    isSending = false
                    return@launch
            }
            ProtocolManager.addLog(
                    "📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
            )

            try {
                val groupStartedAt = System.currentTimeMillis()
                val encryptionContext =
                        buildEncryptionContext(
                                plaintext = text,
                                recipient = recipient,
                                privateKey = privateKey
                        ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
                val encryptedContent = encryptionContext.encryptedContent
                val encryptedKey = encryptionContext.encryptedKey
                val aesChachaKey = encryptionContext.aesChachaKey
                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
                val isSavedMessages = sender == recipient

                val networkAttachments = mutableListOf<MessageAttachment>()
                val finalDbAttachments = JSONArray()
                val finalAttachmentsById = mutableMapOf<String, MessageAttachment>()

                for ((originalIndex, imageData) in preparedImages) {
                    val attachmentId = attachmentIds[originalIndex]
                    val encryptedImageBlob =
                            encryptAttachmentPayload(imageData.base64, encryptionContext)
                    val uploadTag =
                            if (!isSavedMessages) {
                                TransportManager.uploadFile(attachmentId, encryptedImageBlob)
                            } else {
                                ""
                            }
                    val previewWithTag =
                            if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}"
                            else imageData.blurhash

                    AttachmentFileManager.saveAttachment(
                            context = context,
                            blob = imageData.base64,
                            attachmentId = attachmentId,
                            publicKey = sender,
                            privateKey = privateKey
                    )

                    val finalAttachment =
                            MessageAttachment(
                                    id = attachmentId,
                                    blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
                                    type = AttachmentType.IMAGE,
                                    preview = previewWithTag,
                                    width = imageData.width,
                                    height = imageData.height,
                                    localUri = ""
                            )
                    networkAttachments.add(finalAttachment)
                    finalAttachmentsById[attachmentId] = finalAttachment

                    finalDbAttachments.put(
                            JSONObject().apply {
                                put("id", attachmentId)
                                put("type", AttachmentType.IMAGE.value)
                                put("preview", previewWithTag)
                                put("blob", "")
                                put("width", imageData.width)
                                put("height", imageData.height)
                            }
                    )
                }

                val packet =
                        PacketMessage().apply {
                            fromPublicKey = sender
                            toPublicKey = recipient
                            content = encryptedContent
                            chachaKey = encryptedKey
                            this.aesChachaKey = aesChachaKey
                            this.timestamp = timestamp
                            this.privateKey = privateKeyHash
                            this.messageId = messageId
                            attachments = networkAttachments
                        }

                if (!isSavedMessages) {
                    ProtocolManager.send(packet)
                }

                updateMessageStatusAndAttachmentsInDb(
                        messageId = messageId,
                        delivered = if (isSavedMessages) 1 else 0,
                        attachmentsJson = finalDbAttachments.toString()
                )

                withContext(Dispatchers.Main) {
                    _messages.value =
                            _messages.value.map { msg ->
                                if (msg.id != messageId) return@map msg
                                msg.copy(
                                        status =
                                                if (isSavedMessages) MessageStatus.SENT
                                                else MessageStatus.SENDING,
                                        attachments =
                                                msg.attachments.map { current ->
                                                    val final = finalAttachmentsById[current.id]
                                                    if (final != null) {
                                                        current.copy(
                                                                preview = final.preview,
                                                                width = final.width,
                                                                height = final.height,
                                                                localUri = ""
                                                        )
                                                    } else {
                                                        current.copy(localUri = "")
                                                    }
                                                }
                                )
                            }
                    updateCacheFromCurrentMessages()
                }

                saveDialog(
                        lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
                        timestamp = timestamp,
                        opponentPublicKey = recipient
                )
                logPhotoPipeline(
                        messageId,
                        "group-from-uri completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
                )
            } catch (e: Exception) {
                logPhotoPipelineError(messageId, "group-from-uri", e)
                updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
                withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
            } finally {
                isSending = false
            }
        }
    }

    fun sendImageGroup(images: List<ImageData>, caption: String = "") {
        if (images.isEmpty()) return

        // Если одно изображение - отправляем обычным способом
        if (images.size == 1) {
            sendImageMessage(
                    images[0].base64,
                    images[0].blurhash,
                    caption,
                    images[0].width,
                    images[0].height
            )
            return
        }

        val recipient = opponentKey
        val sender = myPublicKey
        val privateKey = myPrivateKey

        if (recipient == null || sender == null || privateKey == null) {
            return
        }
        if (isSending) {
            return
        }

        isSending = true

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()
        val text = caption.trim()
        logPhotoPipeline(
                messageId,
                "group start: count=${images.size}, captionLen=${text.length}"
        )

        // Создаём attachments для всех изображений
        val attachmentsList =
                images.mapIndexed { index, imageData ->
                    MessageAttachment(
                            id = "img_${timestamp}_$index",
                            type = AttachmentType.IMAGE,
                            preview = imageData.blurhash,
                            blob = imageData.base64, // Для локального отображения
                            width = imageData.width,
                            height = imageData.height
                    )
                }

        // 1. 🚀 Optimistic UI
        val optimisticMessage =
                ChatMessage(
                        id = messageId,
                        text = text,
                        isOutgoing = true,
                        timestamp = Date(timestamp),
                        status = MessageStatus.SENDING,
                        attachments = attachmentsList
                )
        // 🔥 Безопасное добавление с проверкой дубликатов
        addMessageSafely(optimisticMessage)
        _inputText.value = ""

        backgroundUploadScope.launch {
            try {
                val groupStartedAt = System.currentTimeMillis()
                // Шифрование текста
                val encryptionContext =
                        buildEncryptionContext(
                                plaintext = text,
                                recipient = recipient,
                                privateKey = privateKey
                        ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
                val encryptedContent = encryptionContext.encryptedContent
                val encryptedKey = encryptionContext.encryptedKey
                val aesChachaKey = encryptionContext.aesChachaKey
                logPhotoPipeline(
                        messageId,
                        "group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}"
                )

                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)

                // Загружаем каждое изображение на Transport Server и создаём attachments
                val networkAttachments = mutableListOf<MessageAttachment>()
                val attachmentsJsonArray = JSONArray()

                for ((index, imageData) in images.withIndex()) {
                    val attachmentId = "img_${timestamp}_$index"
                    logPhotoPipeline(
                            messageId,
                            "group item#$index start: id=${shortPhotoId(attachmentId, 12)}, size=${imageData.width}x${imageData.height}"
                    )

                    // Шифруем изображение с ChaCha ключом
                    val encryptedImageBlob =
                            encryptAttachmentPayload(imageData.base64, encryptionContext)

                    // Загружаем на Transport Server
                    val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
                    val previewWithTag =
                            if (uploadTag != null) {
                                "$uploadTag::${imageData.blurhash}"
                            } else {
                                imageData.blurhash
                            }
                    logPhotoPipeline(
                            messageId,
                            "group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}"
                    )

                    // Сохраняем в файл локально
                    AttachmentFileManager.saveAttachment(
                            context = getApplication(),
                            blob = imageData.base64,
                            attachmentId = attachmentId,
                            publicKey = sender,
                            privateKey = privateKey
                    )

                    // Для сети
                    networkAttachments.add(
                            MessageAttachment(
                                    id = attachmentId,
                                    blob = if (uploadTag != null) "" else encryptedImageBlob,
                                    type = AttachmentType.IMAGE,
                                    preview = previewWithTag,
                                    width = imageData.width,
                                    height = imageData.height
                            )
                    )

                    // Для БД
                    attachmentsJsonArray.put(
                            JSONObject().apply {
                                put("id", attachmentId)
                                put("type", AttachmentType.IMAGE.value)
                                put("preview", previewWithTag)
                                put("blob", "") // Пустой blob - изображения в файловой системе
                                put("width", imageData.width)
                                put("height", imageData.height)
                            }
                    )
                }

                // Создаём пакет
                val packet =
                        PacketMessage().apply {
                            fromPublicKey = sender
                            toPublicKey = recipient
                            content = encryptedContent
                            chachaKey = encryptedKey
                            this.aesChachaKey = aesChachaKey
                            this.timestamp = timestamp
                            this.privateKey = privateKeyHash
                            this.messageId = messageId
                            attachments = networkAttachments
                        }

                // Для Saved Messages не отправляем на сервер
                val isSavedMessages = (sender == recipient)
                if (!isSavedMessages) {
                    ProtocolManager.send(packet)
                    logPhotoPipeline(messageId, "group packet sent")
                }

                // Сохраняем в БД
                saveMessageToDatabase(
                        messageId = messageId,
                        text = text,
                        encryptedContent = encryptedContent,
                        encryptedKey =
                                if (encryptionContext.isGroup) {
                                    buildStoredGroupKey(
                                            encryptionContext.attachmentPassword,
                                            privateKey
                                    )
                                } else {
                                    encryptedKey
                                },
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered = if (isSavedMessages) 1 else 0,
                        attachmentsJson = attachmentsJsonArray.toString(),
                        opponentPublicKey = recipient
                )

                // Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
                withContext(Dispatchers.Main) {
                    if (isSavedMessages) {
                        updateMessageStatus(messageId, MessageStatus.SENT)
                    }
                }

                saveDialog(
                        lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
                        timestamp = timestamp,
                        opponentPublicKey = recipient
                )
                logPhotoPipeline(
                        messageId,
                        "group completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
                )
            } catch (e: Exception) {
                logPhotoPipelineError(messageId, "group-send", e)
                withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
            } finally {
                isSending = false
            }
        }
    }

    /**
     * 📄 Отправка сообщения с файлом
     * @param fileBase64 Base64 содержимого файла
     * @param fileName Имя файла
     * @param fileSize Размер файла в байтах
     * @param caption Подпись к файлу (опционально)
     */
    fun sendFileMessage(
            fileBase64: String,
            fileName: String,
            fileSize: Long,
            caption: String = ""
    ) {
        val recipient = opponentKey
        val sender = myPublicKey
        val privateKey = myPrivateKey

        if (recipient == null || sender == null || privateKey == null) {
            return
        }
        if (isSending) {
            return
        }

        isSending = true

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()
        val text = caption.trim()
        val preview = "$fileSize::$fileName" // Format: "size::name" как в Desktop

        // 1. 🚀 Optimistic UI
        val optimisticMessage =
                ChatMessage(
                        id = messageId,
                        text = text,
                        isOutgoing = true,
                        timestamp = Date(timestamp),
                        status = MessageStatus.SENDING,
                        attachments =
                                listOf(
                                        MessageAttachment(
                                                id = "file_$timestamp",
                                                type = AttachmentType.FILE,
                                                preview = preview,
                                                blob = fileBase64
                                        )
                                )
                )
        // 🔥 Безопасное добавление с проверкой дубликатов
        addMessageSafely(optimisticMessage)
        _inputText.value = ""

        viewModelScope.launch(Dispatchers.IO) {
            try {
                // 💾 Сохраняем файл локально чтобы отправитель мог его открыть
                try {
                    val app = getApplication<android.app.Application>()
                    val downloadsDir = java.io.File(app.filesDir, "rosetta_downloads").apply { mkdirs() }
                    val localFile = java.io.File(downloadsDir, fileName)
                    if (!localFile.exists()) {
                        val base64Data = if (fileBase64.contains(",")) {
                            fileBase64.substringAfter(",")
                        } else {
                            fileBase64
                        }
                        val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
                        localFile.writeBytes(bytes)
                    }
                } catch (_: Exception) {}

                val encryptionContext =
                        buildEncryptionContext(
                                plaintext = text,
                                recipient = recipient,
                                privateKey = privateKey
                        ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
                val encryptedContent = encryptionContext.encryptedContent
                val encryptedKey = encryptionContext.encryptedKey
                val aesChachaKey = encryptionContext.aesChachaKey

                val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)

                // 🚀 Шифруем файл с ChaCha ключом для Transport Server
                val encryptedFileBlob = encryptAttachmentPayload(fileBase64, encryptionContext)

                val attachmentId = "file_$timestamp"

                // 📤 Загружаем на Transport Server (как в desktop)
                // НЕ для Saved Messages - там не нужно загружать
                val isSavedMessages = (sender == recipient)
                var uploadTag = ""

                if (!isSavedMessages) {
                    uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob)
                }

                // Preview содержит tag::size::name (как в desktop)
                val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$preview" else preview

                val fileAttachment =
                        MessageAttachment(
                                id = attachmentId,
                                blob = "", // 🔥 Пустой blob - файл на Transport Server!
                                type = AttachmentType.FILE,
                                preview = previewWithTag
                        )

                val packet =
                        PacketMessage().apply {
                            fromPublicKey = sender
                            toPublicKey = recipient
                            content = encryptedContent
                            chachaKey = encryptedKey
                            this.aesChachaKey = aesChachaKey
                            this.timestamp = timestamp
                            this.privateKey = privateKeyHash
                            this.messageId = messageId
                            attachments = listOf(fileAttachment)
                        }

                // Отправляем пакет (без blob!)
                if (!isSavedMessages) {
                    ProtocolManager.send(packet)
                }

                // ⚠️ НЕ сохраняем файл локально - они слишком большие
                // Файлы загружаются с Transport Server при необходимости
                val attachmentsJson =
                        JSONArray()
                                .apply {
                                    put(
                                            JSONObject().apply {
                                                put("id", attachmentId)
                                                put("type", AttachmentType.FILE.value)
                                                put("preview", previewWithTag)
                                                put("blob", "") // Пустой blob - не сохраняем в БД!
                                            }
                                    )
                                }
                                .toString()

                // 🔥 Сохраняем сначала с SENDING, потом обновляем на SENT
                saveMessageToDatabase(
                        messageId = messageId,
                        text = text,
                        encryptedContent = encryptedContent,
                        encryptedKey =
                                if (encryptionContext.isGroup) {
                                    buildStoredGroupKey(
                                            encryptionContext.attachmentPassword,
                                            privateKey
                                    )
                                } else {
                                    encryptedKey
                                },
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered =
                                if (isSavedMessages) 1
                                else 0, // SENDING для обычных, DELIVERED для saved
                        attachmentsJson = attachmentsJson
                )

                // Для обычных чатов статус подтверждаем только по PacketDelivery(messageId).
                withContext(Dispatchers.Main) {
                    if (isSavedMessages) {
                        updateMessageStatus(messageId, MessageStatus.SENT)
                    }
                }

                saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
            } catch (e: Exception) {
                withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
                updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
                saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
            } finally {
                isSending = false
            }
        }
    }

    /**
     * Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
     */
    fun sendAvatarMessage() {
        val recipient = opponentKey
        val sender = myPublicKey
        val userPrivateKey = myPrivateKey

        if (recipient == null || sender == null || userPrivateKey == null) {
            return
        }
        if (isSending) {
            return
        }

        isSending = true

        val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()

        viewModelScope.launch(Dispatchers.IO) {
            try {
                // Получаем свой аватар из AvatarRepository
                val avatarDao = database.avatarDao()
                val myAvatar = avatarDao.getLatestAvatar(sender)

                if (myAvatar == null) {
                    withContext(Dispatchers.Main) {
                        android.widget.Toast.makeText(
                                        getApplication(),
                                        "No avatar to send",
                                        android.widget.Toast.LENGTH_SHORT
                                )
                                .show()
                    }
                    isSending = false
                    return@launch
                }

                // Читаем и расшифровываем аватар
                val avatarBlob =
                        com.rosetta.messenger.utils.AvatarFileManager.readAvatar(
                                getApplication(),
                                myAvatar.avatar
                        )

                if (avatarBlob == null || avatarBlob.isEmpty()) {
                    withContext(Dispatchers.Main) {
                        android.widget.Toast.makeText(
                                        getApplication(),
                                        "Failed to read avatar",
                                        android.widget.Toast.LENGTH_SHORT
                                )
                                .show()
                    }
                    isSending = false
                    return@launch
                }

                // 🔥 КРИТИЧНО: Desktop ожидает полный data URL, а не просто Base64!
                // Добавляем префикс если его нет
                val avatarDataUrl =
                        if (avatarBlob.startsWith("data:image")) {
                            avatarBlob
                        } else {
                            "data:image/png;base64,$avatarBlob"
                        }

                // Генерируем blurhash для preview (как на desktop)
                val avatarBlurhash =
                        withContext(Dispatchers.IO) {
                            try {
                                val bitmap = base64ToBitmap(avatarBlob)
                                if (bitmap != null) {
                                    com.rosetta.messenger.utils.MediaUtils
                                            .generateBlurhashFromBitmap(bitmap)
                                } else {
                                    ""
                                }
                            } catch (e: Exception) {
                                ""
                            }
                        }

                // 1. 🚀 Optimistic UI
                val optimisticMessage =
                        ChatMessage(
                                id = messageId,
                                text = "",
                                isOutgoing = true,
                                timestamp = Date(timestamp),
                                status = MessageStatus.SENDING,
                                attachments =
                                        listOf(
                                                MessageAttachment(
                                                        id = "avatar_$timestamp",
                                                        type = AttachmentType.AVATAR,
                                                        preview = avatarBlurhash,
                                                        blob = avatarBlob // Для локального
                                                        // отображения
                                                        )
                                        )
                        )
                withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) }

                // 2. Шифрование текста (пустой текст для аватарки)
                val encryptionContext =
                        buildEncryptionContext(
                                plaintext = "",
                                recipient = recipient,
                                privateKey = userPrivateKey
                        ) ?: throw IllegalStateException("Cannot resolve chat encryption context")
                val encryptedContent = encryptionContext.encryptedContent
                val encryptedKey = encryptionContext.encryptedKey
                val aesChachaKey = encryptionContext.aesChachaKey

                val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)

                // 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce)
                // НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения
                // Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob!
                val encryptedAvatarBlob = encryptAttachmentPayload(avatarDataUrl, encryptionContext)

                val avatarAttachmentId = "avatar_$timestamp"

                // 📤 Загружаем на Transport Server (как в desktop!)
                val isSavedMessages = (sender == recipient)
                var uploadTag = ""

                if (!isSavedMessages) {
                    try {
                        uploadTag =
                                TransportManager.uploadFile(avatarAttachmentId, encryptedAvatarBlob)
                    } catch (e: Exception) {
                        throw e
                    }
                }

                // Preview содержит tag::blurhash (как в desktop)
                val previewWithTag =
                        if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash"
                        else avatarBlurhash

                val avatarAttachment =
                        MessageAttachment(
                                id = avatarAttachmentId,
                                blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server!
                                type = AttachmentType.AVATAR,
                                preview = previewWithTag
                        )

                // 3. Отправляем пакет (с ПУСТЫМ blob!)
                val packet =
                        PacketMessage().apply {
                            fromPublicKey = sender
                            toPublicKey = recipient
                            content = encryptedContent
                            chachaKey = encryptedKey
                            this.aesChachaKey = aesChachaKey
                            this.timestamp = timestamp
                            this.privateKey = privateKeyHash
                            this.messageId = messageId
                            attachments = listOf(avatarAttachment)
                        }

                if (!isSavedMessages) {
                    ProtocolManager.send(packet)
                }

                // 💾 Сохраняем аватар в файл локально (как IMAGE - с приватным ключом)
                AttachmentFileManager.saveAttachment(
                        context = getApplication(),
                        blob = avatarBlob,
                        attachmentId = avatarAttachmentId,
                        publicKey = sender,
                        privateKey = userPrivateKey
                )

                // 4. 💾 Сохраняем в БД (БЕЗ blob - он в файле) - как в sendImageMessage
                val attachmentsJson =
                        JSONArray()
                                .apply {
                                    put(
                                            JSONObject().apply {
                                                put("id", avatarAttachmentId)
                                                put("type", AttachmentType.AVATAR.value)
                                                put("preview", previewWithTag) // tag::blurhash
                                                put("blob", "") // Пустой blob - не сохраняем в БД!
                                            }
                                    )
                                }
                                .toString()

                saveMessageToDatabase(
                        messageId = messageId,
                        text = "", // Аватар без текста
                        encryptedContent = encryptedContent,
                        encryptedKey =
                                if (encryptionContext.isGroup) {
                                    buildStoredGroupKey(
                                            encryptionContext.attachmentPassword,
                                            userPrivateKey
                                    )
                                } else {
                                    encryptedKey
                                },
                        timestamp = timestamp,
                        isFromMe = true,
                        delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage
                        attachmentsJson = attachmentsJson
                )

                // Обновляем UI: для обычных чатов остаёмся в SENDING до PacketDelivery(messageId).
                withContext(Dispatchers.Main) {
                    if (isSavedMessages) {
                        updateMessageStatus(messageId, MessageStatus.SENT)
                    }
                }

                saveDialog("\$a=Avatar", timestamp)
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    updateMessageStatus(messageId, MessageStatus.ERROR)
                    android.widget.Toast.makeText(
                                    getApplication(),
                                    "Failed to send avatar: ${e.message}",
                                    android.widget.Toast.LENGTH_SHORT
                            )
                            .show()
                }
                updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
                saveDialog("\$a=Avatar", timestamp)
            } finally {
                isSending = false
            }
        }
    }

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

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

            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) {}
    }

    /**
     * Обновить диалог при входящем сообщении 📁 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 = "[]",
            opponentPublicKey: String? = null
    ) {
        val account = myPublicKey ?: return
        val opponent = opponentPublicKey ?: opponentKey ?: return
        val privateKey = myPrivateKey ?: 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,
                            replyToMessageId = null,
                            dialogKey = dialogKey
                    )

            val insertedId = messageDao.insertMessage(entity)
        } catch (e: Exception) {}
    }

    private fun showTypingIndicator() {
        _opponentTyping.value = true
        // Отменяем предыдущий таймер, чтобы избежать race condition
        typingTimeoutJob?.cancel()
        typingTimeoutJob =
                viewModelScope.launch(Dispatchers.Default) {
                    kotlinx.coroutines.delay(3000)
                    _opponentTyping.value = false
                }
    }

    /**
     * 📝 Отправить индикатор "печатает..." С throttling чтобы не спамить сервер 📁 Для Saved
     * Messages - не отправляем (нельзя печатать самому себе)
     */
    fun sendTypingIndicator() {
        val now = System.currentTimeMillis()
        if (now - lastTypingSentTime < TYPING_THROTTLE_MS) return

        val opponent =
                opponentKey
                        ?: run {
                            return
                        }
        val sender =
                myPublicKey
                        ?: run {
                            return
                        }

        // 📁 Для Saved Messages - не отправляем typing indicator
        if (opponent.equals(sender, ignoreCase = true)) {
            return
        }

        val privateKey =
                myPrivateKey
                        ?: run {
                            return
                        }

        lastTypingSentTime = now

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

                val packet =
                        PacketTyping().apply {
                            this.privateKey = privateKeyHash
                            fromPublicKey = sender
                            toPublicKey = opponent
                        }

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

    /**
     * 👁️ Отправить 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)

                // Desktop формат: privateKey, fromPublicKey, toPublicKey
                val packet =
                        PacketRead().apply {
                            this.privateKey = privateKeyHash
                            fromPublicKey = sender // Мы (кто прочитал)
                            toPublicKey = opponent // Кому отправляем уведомление (собеседник)
                        }

                ProtocolManager.send(packet)
                // ✅ Обновляем timestamp ПОСЛЕ успешной отправки
                lastReadMessageTimestamp = incomingTs
                MessageLogger.logReadReceiptSent(opponent)
            } catch (e: Exception) {
                MessageLogger.logReadReceiptFailed(opponent, e)
                // 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную)
                try {
                    kotlinx.coroutines.delay(2000)
                    ProtocolManager.send(
                            PacketRead().apply {
                                this.privateKey = 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)
                        }

                ProtocolManager.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
        pinnedCollectionJob?.cancel()

        // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
        ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
        ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)

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