package com.rosetta.messenger.data

import android.content.Context
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.database.*
import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONArray
import org.json.JSONObject

/** UI модель сообщения */
data class Message(
        val id: Long = 0,
        val messageId: String,
        val fromPublicKey: String,
        val toPublicKey: String,
        val content: String, // Расшифрованный текст
        val timestamp: Long,
        val isFromMe: Boolean,
        val isRead: Boolean,
        val deliveryStatus: DeliveryStatus,
        val attachments: List<MessageAttachment> = emptyList(),
        val replyToMessageId: String? = null
)

data class Dialog(
        val opponentKey: String,
        val opponentTitle: String,
        val opponentUsername: String,
        val lastMessage: String,
        val lastMessageTimestamp: Long,
        val unreadCount: Int,
        val isOnline: Boolean,
        val lastSeen: Long,
        val verified: Boolean
)

/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
@Singleton
class MessageRepository @Inject constructor(
        @ApplicationContext private val context: Context,
        private val protocolClient: ProtocolClient
) {

    private val database = RosettaDatabase.getDatabase(context)
    private val messageDao = database.messageDao()
    private val dialogDao = database.dialogDao()
    private val avatarDao = database.avatarDao()
    private val syncTimeDao = database.syncTimeDao()
    private val groupDao = database.groupDao()
    private val searchIndexDao = database.messageSearchIndexDao()

    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    // Кэш сообщений по диалогам
    private val messageCache = mutableMapOf<String, MutableStateFlow<List<Message>>>()

    // Кэш диалогов
    private val _dialogs = MutableStateFlow<List<Dialog>>(emptyList())
    val dialogs: StateFlow<List<Dialog>> = _dialogs.asStateFlow()

    // 🔔 События новых сообщений для обновления UI в реальном времени
    // 🔥 Увеличен буфер до 64 + DROP_OLDEST для защиты от переполнения при спаме
    private val _newMessageEvents =
            MutableSharedFlow<String>(
                    replay = 0,
                    extraBufferCapacity = 64,
                    onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
            )
    val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()

    // 🔔 События обновления статуса доставки для UI
    data class DeliveryStatusUpdate(
            val dialogKey: String,
            val messageId: String,
            val status: DeliveryStatus
    )

    private val _deliveryStatusEvents =
            MutableSharedFlow<DeliveryStatusUpdate>(
                    replay = 0,
                    extraBufferCapacity = 64,
                    onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
            )
    val deliveryStatusEvents: SharedFlow<DeliveryStatusUpdate> =
            _deliveryStatusEvents.asSharedFlow()

    // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
    private val requestedUserInfoKeys = mutableSetOf<String>()

    // Текущий аккаунт
    private var currentAccount: String? = null
    private var currentPrivateKey: String? = null

    companion object {
        /** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
        private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L

        const val SYSTEM_SAFE_PUBLIC_KEY = "0x000000000000000000000000000000000000000002"
        const val SYSTEM_SAFE_TITLE = "Safe"
        const val SYSTEM_SAFE_USERNAME = "safe"

        const val SYSTEM_UPDATES_PUBLIC_KEY = "0x000000000000000000000000000000000000000001"
        const val SYSTEM_UPDATES_TITLE = "Rosetta Updates"
        const val SYSTEM_UPDATES_USERNAME = "updates"

        /** All system account public keys */
        val SYSTEM_ACCOUNT_KEYS = setOf(SYSTEM_SAFE_PUBLIC_KEY, SYSTEM_UPDATES_PUBLIC_KEY)

        fun isSystemAccount(publicKey: String): Boolean = publicKey in SYSTEM_ACCOUNT_KEYS

        // 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
        // LRU кэш с ограничением 1000 элементов - защита от race conditions
        private val processedMessageIds =
                java.util.Collections.synchronizedSet(
                        object : LinkedHashSet<String>() {
                            override fun add(element: String): Boolean {
                                if (size >= 1000) remove(first())
                                return super.add(element)
                            }
                        }
                )

        /**
         * Помечает messageId как обработанный и возвращает true если это новый ID Возвращает false
         * если сообщение уже было обработано (дубликат)
         */
        fun markAsProcessed(messageId: String): Boolean = processedMessageIds.add(messageId)

        /** Очистка кэша (вызывается при logout) */
        fun clearProcessedCache() = processedMessageIds.clear()

        /**
         * Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
         * хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
         */
        fun generateMessageId(fromPublicKey: String, toPublicKey: String, timestamp: Long): String {
            // Генерируем UUID для гарантии уникальности
            return UUID.randomUUID().toString().replace("-", "").take(32)
        }
    }

    suspend fun addDeviceLoginSystemMessage(
            ipAddress: String,
            deviceId: String,
            deviceName: String,
            deviceOs: String
    ) {
        val account = currentAccount ?: return
        val privateKey = currentPrivateKey ?: return

        val safeIp = ipAddress.ifBlank { "unknown" }
        val safeDeviceName = deviceName.ifBlank { "Unknown device" }
        val safeDeviceOs = deviceOs.ifBlank { "unknown" }
        val safeDeviceId = dotCenterIfNeeded(deviceId.ifBlank { "unknown" }, maxLength = 12, side = 4)

        val messageText =
                """
                Attempt to login from a new device

                We detected a login to your account from $safeIp a new device by seed phrase. If this was you, you can safely ignore this message.

                Arch: $safeDeviceOs
                IP: $safeIp
                Device: $safeDeviceName
                ID: $safeDeviceId
                """
                        .trimIndent()

        val encryptedPlainMessage =
                try {
                    CryptoManager.encryptWithPassword(messageText, privateKey)
                } catch (_: Exception) {
                    return
                }

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

        val inserted =
                messageDao.insertMessage(
                        MessageEntity(
                                account = account,
                                fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
                                toPublicKey = account,
                                content = "",
                                timestamp = timestamp,
                                chachaKey = "",
                                read = 0,
                                fromMe = 0,
                                delivered = DeliveryStatus.DELIVERED.value,
                                messageId = messageId,
                                plainMessage = encryptedPlainMessage,
                                attachments = "[]",
                                primaryAttachmentType = -1,
                                dialogKey = dialogKey
                        )
                )

        if (inserted == -1L) return

        val insertedMessage =
                MessageEntity(
                        account = account,
                        fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
                        toPublicKey = account,
                        content = "",
                        timestamp = timestamp,
                        chachaKey = "",
                        read = 0,
                        fromMe = 0,
                        delivered = DeliveryStatus.DELIVERED.value,
                        messageId = messageId,
                        plainMessage = encryptedPlainMessage,
                        attachments = "[]",
                        primaryAttachmentType = -1,
                        dialogKey = dialogKey
                )
        upsertSearchIndex(account, insertedMessage, messageText)

        val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
        dialogDao.insertDialog(
                DialogEntity(
                        id = existing?.id ?: 0,
                        account = account,
                        opponentKey = SYSTEM_SAFE_PUBLIC_KEY,
                        opponentTitle = existing?.opponentTitle?.ifBlank { SYSTEM_SAFE_TITLE } ?: SYSTEM_SAFE_TITLE,
                        opponentUsername =
                                existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
                                        ?: SYSTEM_SAFE_USERNAME,
                        lastMessage = encryptedPlainMessage,
                        lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
                        hasContent = 1,
                        lastMessageFromMe = 0,
                        lastMessageDelivered = DeliveryStatus.DELIVERED.value,
                        lastMessageRead = 0,
                        lastMessageAttachments = "[]",
                        isOnline = existing?.isOnline ?: 0,
                        lastSeen = existing?.lastSeen ?: 0,
                        verified = maxOf(existing?.verified ?: 0, 1),
                        iHaveSent = 1
                )
        )

        dialogDao.updateDialogFromMessages(account, SYSTEM_SAFE_PUBLIC_KEY)
        _newMessageEvents.tryEmit(dialogKey)
    }

    /** Send a system message from "Rosetta Updates" account. Returns messageId or null. */
    suspend fun addUpdateSystemMessage(messageText: String): String? {
        val account = currentAccount ?: return null
        val privateKey = currentPrivateKey ?: return null

        val encryptedPlainMessage =
                try {
                    CryptoManager.encryptWithPassword(messageText, privateKey)
                } catch (e: Exception) {
                    if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
                    return null
                }

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

        val inserted =
                messageDao.insertMessage(
                        MessageEntity(
                                account = account,
                                fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
                                toPublicKey = account,
                                content = "",
                                timestamp = timestamp,
                                chachaKey = "",
                                read = 0,
                                fromMe = 0,
                                delivered = DeliveryStatus.DELIVERED.value,
                                messageId = messageId,
                                plainMessage = encryptedPlainMessage,
                                attachments = "[]",
                                primaryAttachmentType = -1,
                                dialogKey = dialogKey
                        )
                )

        if (inserted == -1L) return null

        val insertedMessage =
                MessageEntity(
                        account = account,
                        fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
                        toPublicKey = account,
                        content = "",
                        timestamp = timestamp,
                        chachaKey = "",
                        read = 0,
                        fromMe = 0,
                        delivered = DeliveryStatus.DELIVERED.value,
                        messageId = messageId,
                        plainMessage = encryptedPlainMessage,
                        attachments = "[]",
                        primaryAttachmentType = -1,
                        dialogKey = dialogKey
                )
        upsertSearchIndex(account, insertedMessage, messageText)

        val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
        dialogDao.insertDialog(
                DialogEntity(
                        id = existing?.id ?: 0,
                        account = account,
                        opponentKey = SYSTEM_UPDATES_PUBLIC_KEY,
                        opponentTitle = existing?.opponentTitle?.ifBlank { SYSTEM_UPDATES_TITLE } ?: SYSTEM_UPDATES_TITLE,
                        opponentUsername =
                                existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
                                        ?: SYSTEM_UPDATES_USERNAME,
                        lastMessage = encryptedPlainMessage,
                        lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
                        hasContent = 1,
                        lastMessageFromMe = 0,
                        lastMessageDelivered = DeliveryStatus.DELIVERED.value,
                        lastMessageRead = 0,
                        lastMessageAttachments = "[]",
                        isOnline = existing?.isOnline ?: 0,
                        lastSeen = existing?.lastSeen ?: 0,
                        verified = maxOf(existing?.verified ?: 0, 1),
                        iHaveSent = 1
                )
        )

        dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY)
        _newMessageEvents.tryEmit(dialogKey)
        return messageId
    }

    /**
     * Check if app version changed and send update message from "Rosetta Updates"
     * Called after account initialization, like desktop useUpdateMessage hook
     */
    suspend fun checkAndSendVersionUpdateMessage() {
        val account = currentAccount
        if (account == null) {
            if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
            return
        }
        val privateKey = currentPrivateKey
        if (privateKey == null) {
            if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
            return
        }
        val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
        val lastNoticeKey = prefs.getString("lastNoticeKey", "") ?: ""
        val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
        val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"

        if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")

        if (lastNoticeKey != currentKey) {
            // Delete the previous message for this version (if any)
            val prevMessageId = prefs.getString("lastNoticeMessageId_$currentVersion", null)
            if (prevMessageId != null) {
                messageDao.deleteMessage(account, prevMessageId)
                dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY)
            }

            val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
            if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
            if (messageId != null) {
                prefs.edit()
                    .putString("lastNoticeKey", currentKey)
                    .putString("lastNoticeMessageId_$currentVersion", messageId)
                    .apply()
                if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
            } else {
                if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
            }
        }
    }

    /** Инициализация с текущим аккаунтом */
    fun initialize(publicKey: String, privateKey: String) {
        val start = System.currentTimeMillis()
        // 🔥 Очищаем кэши при смене аккаунта
        if (currentAccount != publicKey) {
            requestedUserInfoKeys.clear()
            // Clear message Flow cache — dialogKey is account-independent (sorted pair),
            // so stale Flows from previous account would return wrong data
            messageCache.clear()
            // Clear processed messageIds — prevents blocking delivery of messages
            // that were already processed under the previous account
            clearProcessedCache()
        }

        currentAccount = publicKey
        currentPrivateKey = privateKey
        // 🚀 ОПТИМИЗАЦИЯ: Убрана дублирующая подписка на dialogDao.getDialogsFlow()
        // Подписка на диалоги и загрузка user-info уже выполняется в
        // ChatsListViewModel.setAccount()
        // Дублирование вызывало двойную обработку каждого обновления таблицы dialogs
    }

    /** Проверка инициализации */
    fun isInitialized(): Boolean {
        return currentAccount != null && currentPrivateKey != null
    }

    /**
     * Clear active account binding for this process session.
     * Must be called on explicit logout/account switch so stale account context
     * cannot be reused after reconnect.
     */
    fun clearInitialization() {
        currentAccount = null
        currentPrivateKey = null
        requestedUserInfoKeys.clear()
        messageCache.clear()
        clearProcessedCache()
    }

    fun getCurrentAccountKey(): String? = currentAccount

    fun getCurrentPrivateKey(): String? = currentPrivateKey

    suspend fun getLastSyncTimestamp(): Long {
        val account = currentAccount ?: return 0L
        return syncTimeDao.getLastSync(account) ?: 0L
    }

    suspend fun updateLastSyncTimestamp(timestamp: Long) {
        val account = currentAccount ?: return
        // iOS parity: normalize timestamp to milliseconds before storing.
        // Server may send seconds or milliseconds depending on the packet.
        val normalized = normalizeSyncTimestamp(timestamp)
        if (normalized <= 0L) return
        val existing = syncTimeDao.getLastSync(account) ?: 0L
        if (normalized <= existing) return
        syncTimeDao.upsert(AccountSyncTimeEntity(account = account, lastSync = normalized))
    }

    /**
     * iOS parity: normalize sync timestamp to milliseconds.
     * Values below 1_000_000_000_000 are assumed to be in seconds.
     */
    private fun normalizeSyncTimestamp(raw: Long): Long {
        return if (raw < 1_000_000_000_000L) raw * 1000L else raw
    }

    /**
     * Normalize incoming message timestamp for chat ordering:
     * 1) accept both seconds and milliseconds;
     * 2) never allow a message timestamp from the future on this device.
     */
    private fun normalizeIncomingPacketTimestamp(rawTimestamp: Long, receivedAtMs: Long): Long {
        val normalizedRaw =
                if (rawTimestamp in 1..999_999_999_999L) rawTimestamp * 1000L else rawTimestamp
        if (normalizedRaw <= 0L) return receivedAtMs
        return minOf(normalizedRaw, receivedAtMs)
    }

    /** Получить поток сообщений для диалога */
    fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
        val dialogKey = getDialogKey(opponentKey)

        return messageCache.getOrPut(dialogKey) {
            MutableStateFlow<List<Message>>(emptyList()).also { flow ->
                scope.launch {
                    currentAccount?.let { account ->
                        messageDao.getMessagesFlow(account, dialogKey).collect { entities ->
                            flow.value = entities.map { it.toMessage() }
                        }
                    }
                }
            }
        }
    }

    /** Отправка сообщения с Optimistic UI Возвращает сразу, шифрование и отправка в фоне */
    suspend fun sendMessage(
            toPublicKey: String,
            text: String,
            attachments: List<MessageAttachment> = emptyList(),
            replyToMessageId: String? = null
    ): Message {
        val account = currentAccount ?: throw IllegalStateException("Not initialized")
        val privateKey = currentPrivateKey ?: throw IllegalStateException("Not initialized")

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

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

        // 📝 LOG: Начало отправки
        MessageLogger.logSendStart(
                messageId = messageId,
                toPublicKey = toPublicKey,
                textLength = text.trim().length,
                attachmentsCount = attachments.size,
                isSavedMessages = isSavedMessages,
                replyToMessageId = replyToMessageId
        )

        // 1. Создаем оптимистичное сообщение
        // 📁 Для saved messages - сразу DELIVERED и прочитано
        val optimisticMessage =
                Message(
                        messageId = messageId,
                        fromPublicKey = account,
                        toPublicKey = toPublicKey,
                        content = text.trim(),
                        timestamp = timestamp,
                        isFromMe = true,
                        isRead = isSavedMessages, // 📁 Если сам себе - сразу прочитано
                        deliveryStatus =
                                if (isSavedMessages) DeliveryStatus.DELIVERED
                                else DeliveryStatus.WAITING, // 📁 Для saved messages - сразу
                        // доставлено
                        attachments = attachments,
                        replyToMessageId = replyToMessageId
                )

        // 2. Обновляем UI сразу (Optimistic Update)
        updateMessageCache(dialogKey, optimisticMessage)
        MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0)

        // 3. Фоновая обработка
        scope.launch {
            val startTime = System.currentTimeMillis()
            try {
                // Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
                val hasContent = text.trim().isNotEmpty()
                val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
                val encryptedContent = encryptResult?.ciphertext ?: ""
                val encryptedKey = encryptResult?.encryptedKey ?: ""
                val aesChachaKey =
                        if (encryptResult != null) {
                            CryptoManager.encryptWithPassword(
                                    String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
                                    privateKey
                            )
                        } else ""

                // 📝 LOG: Шифрование успешно
                MessageLogger.logEncryptionSuccess(
                        messageId = messageId,
                        encryptedContentLength = encryptedContent.length,
                        encryptedKeyLength = encryptedKey.length
                )

                // 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
                // Desktop хранит зашифрованный ключ, расшифровывает только при использовании

                // Сериализуем attachments в JSON
                val attachmentsJson = serializeAttachments(attachments)

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

                // ✅ Проверяем существование - не дублируем сообщения
                val exists = messageDao.messageExists(account, messageId)
                if (!exists) {
                    // Сохраняем в БД только если сообщения нет
                    // 📁 Для saved messages - сразу read=1 и delivered=DELIVERED
                    val entity =
                            MessageEntity(
                                    account = account,
                                    fromPublicKey = account,
                                    toPublicKey = toPublicKey,
                                    content = encryptedContent,
                                    timestamp = timestamp,
                                    chachaKey = encryptedKey,
                                    read = if (isSavedMessages) 1 else 0,
                                    fromMe = 1,
                                    delivered =
                                            if (isSavedMessages) DeliveryStatus.DELIVERED.value
                                            else DeliveryStatus.WAITING.value,
                                    messageId = messageId,
                                    plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
                                    attachments = attachmentsJson,
                                    primaryAttachmentType =
                                            resolvePrimaryAttachmentType(attachments),
                                    replyToMessageId = replyToMessageId,
                                    dialogKey = dialogKey
                            )
                    messageDao.insertMessage(entity)
                    upsertSearchIndex(account, entity, text.trim())

                    // 📝 LOG: Сохранено в БД
                    MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
                } else {
                    MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
                }

                // 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
                dialogDao.updateDialogFromMessages(account, toPublicKey)

                // Notify listeners (ChatViewModel) that a new message was persisted
                // so the chat UI reloads from DB. Without this, messages produced by
                // non-input flows (e.g. CallManager's missed-call attachment) only
                // appear after the user re-enters the chat.
                _newMessageEvents.tryEmit(dialogKey)

                // 📁 Для saved messages - гарантируем создание/обновление dialog
                if (isSavedMessages) {
                    val existing = dialogDao.getDialog(account, account)
                    dialogDao.insertDialog(
                        com.rosetta.messenger.database.DialogEntity(
                            id = existing?.id ?: 0,
                            account = account,
                            opponentKey = account,
                            opponentTitle = existing?.opponentTitle ?: "Saved Messages",
                            opponentUsername = existing?.opponentUsername ?: "",
                            lastMessage = encryptedPlainMessage,
                            lastMessageTimestamp = timestamp,
                            unreadCount = 0,
                            isOnline = 0,
                            lastSeen = existing?.lastSeen ?: 0,
                            verified = existing?.verified ?: 0,
                            iHaveSent = 1,
                            hasContent =
                                    if (
                                            encryptedPlainMessage.isNotBlank() ||
                                                    attachments.isNotEmpty()
                                    ) {
                                        1
                                    } else {
                                        0
                                    },
                            lastMessageAttachmentType = resolvePrimaryAttachmentType(attachments),
                            lastSenderKey = account,
                            lastMessageFromMe = 1,
                            lastMessageDelivered = 1,
                            lastMessageRead = 1,
                            lastMessageAttachments = serializeAttachments(attachments)
                        )
                    )
                }

                // 🔥 Логируем что записалось в диалог
                val dialog = dialogDao.getDialog(account, toPublicKey)
                MessageLogger.logDialogUpdate(
                        dialogKey = dialogKey,
                        lastMessage = dialog?.lastMessage,
                        unreadCount = dialog?.unreadCount ?: 0
                )

                // 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в
                // chats)
                val updatedRows = dialogDao.markIHaveSent(account, toPublicKey)

                // 📁 НЕ отправляем пакет на сервер для saved messages!
                // Как в Архиве: if(publicKey == opponentPublicKey) return;
                if (isSavedMessages) {
                    MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
                    MessageLogger.debug("📁 SavedMessages: skipping server send")
                    return@launch // Для saved messages - только локальное сохранение, без отправки
                    // на сервер
                }

                // Отправляем пакет (только для обычных диалогов)
                val packet =
                        PacketMessage().apply {
                            this.fromPublicKey = account
                            this.toPublicKey = toPublicKey
                            this.content = encryptedContent
                            this.chachaKey = encryptedKey
                            this.aesChachaKey = aesChachaKey
                            this.timestamp = timestamp
                            this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
                            this.messageId = messageId
                            this.attachments = attachments
                        }

                // 📝 LOG: Отправка пакета
                MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)

                // iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
                protocolClient.sendMessageWithRetry(packet)

                // 📝 LOG: Успешная отправка
                MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
            } catch (e: Exception) {
                // 📝 LOG: Ошибка отправки
                MessageLogger.logSendError(messageId, e)

                // При ошибке обновляем статус
                messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value)
                updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR)
                _deliveryStatusEvents.tryEmit(
                        DeliveryStatusUpdate(dialogKey, messageId, DeliveryStatus.ERROR)
                )
            }
        }

        return optimisticMessage
    }

    /** Обработка входящего сообщения. Возвращает true если пакет обработан безопасно. */
    suspend fun handleIncomingMessage(packet: PacketMessage): Boolean {
        val startTime = System.currentTimeMillis()

        val account =
                currentAccount
                        ?: run {
                            MessageLogger.debug("📥 RECEIVE SKIP: account is null")
                            return false
                        }
        val privateKey =
                currentPrivateKey
                        ?: run {
                            MessageLogger.debug("📥 RECEIVE SKIP: privateKey is null")
                            return false
                        }

        // 📝 LOG: Начало обработки входящего сообщения
        MessageLogger.logReceiveStart(
                messageId = packet.messageId,
                fromPublicKey = packet.fromPublicKey,
                toPublicKey = packet.toPublicKey,
                contentLength = packet.content.length,
                attachmentsCount = packet.attachments.size,
                timestamp = packet.timestamp
        )

        val isOwnMessage = packet.fromPublicKey == account
        val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
        val normalizedPacketTimestamp =
                normalizeIncomingPacketTimestamp(packet.timestamp, startTime)
        if (normalizedPacketTimestamp != packet.timestamp) {
            MessageLogger.debug(
                    "📥 TIMESTAMP normalized: raw=${packet.timestamp} -> local=$normalizedPacketTimestamp"
            )
        }

        // 🔥 Проверяем, не заблокирован ли отправитель
        if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
            val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
            if (isBlocked) {
                MessageLogger.logBlockedSender(packet.fromPublicKey)
                return true
            }
        }

        // 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
        val messageId =
                if (packet.messageId.isBlank()) {
                    val generatedId =
                            generateMessageId(
                                    packet.fromPublicKey,
                                    packet.toPublicKey,
                                    packet.timestamp
                            )
                    MessageLogger.debug("📥 Generated messageId: $generatedId (original was blank)")
                    generatedId
                } else {
                    packet.messageId
                }

        // 🔥 ПЕРВЫЙ УРОВЕНЬ ЗАЩИТЫ: In-memory кэш (быстрая проверка без обращения к БД)
        // markAsProcessed возвращает false если сообщение уже обрабатывалось
        if (!markAsProcessed(messageId)) {
            MessageLogger.debug(
                    "📥 SKIP (in-memory cache): Message $messageId already being processed"
            )
            return true
        }

        val dialogOpponentKey =
                when {
                    isGroupMessage -> packet.toPublicKey
                    isOwnMessage -> packet.toPublicKey
                    else -> packet.fromPublicKey
                }
        val dialogKey = getDialogKey(dialogOpponentKey)

        // 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
        val isDuplicate = messageDao.messageExists(account, messageId)
        MessageLogger.logDuplicateCheck(messageId, isDuplicate)
        if (isDuplicate) {
            // Desktop/server parity:
            // own messages that arrive via sync must be treated as delivered.
            // If a local optimistic row already exists (WAITING/ERROR), normalize it.
            if (isOwnMessage) {
                messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.DELIVERED.value)
                messageCache[dialogKey]?.let { flow ->
                    flow.value =
                            flow.value.map { msg ->
                                if (msg.messageId == messageId) {
                                    msg.copy(deliveryStatus = DeliveryStatus.DELIVERED)
                                } else {
                                    msg
                                }
                            }
                }
                _deliveryStatusEvents.tryEmit(
                        DeliveryStatusUpdate(dialogKey, messageId, DeliveryStatus.DELIVERED)
                )
                dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
            }
            return true
        }

        try {
            val groupKey =
                    if (isGroupMessage) {
                        val fromPacket =
                                if (packet.aesChachaKey.isNotBlank()) {
                                    CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
                                } else {
                                    null
                                }
                        fromPacket ?: resolveGroupKeyForDialog(account, privateKey, packet.toPublicKey)
                    } else {
                        null
                    }

            if (isGroupMessage && groupKey.isNullOrBlank()) {
                val requiresGroupKey =
                        (packet.content.isNotBlank() && isProbablyEncryptedPayload(packet.content)) ||
                                packet.attachments.any { it.blob.isNotBlank() }
                if (requiresGroupKey) {
                    MessageLogger.debug(
                            "📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
                    )
                    processedMessageIds.remove(messageId)
                    return false
                }
                protocolClient.addLog(
                        "⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true"
                )
            }

            val plainKeyAndNonce =
                    if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank()) {
                        CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
                                ?.toByteArray(Charsets.ISO_8859_1)
                    } else {
                        null
                    }

            if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
                protocolClient.addLog(
                        "⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
                )
            }

            if (!isGroupMessage && isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
                MessageLogger.debug(
                        "📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
                )
            }

            // Расшифровываем (CALL и attachment-only сообщения могут иметь пустой или
            // зашифрованный пустой content — обрабатываем оба случая безопасно)
            val isAttachmentOnly = packet.content.isBlank() ||
                    (packet.attachments.isNotEmpty() && packet.chachaKey.isBlank())
            val plainText =
                    if (isAttachmentOnly) {
                        ""
                    } else if (isGroupMessage) {
                        val decryptedGroupPayload =
                                groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
                        decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
                    } else if (plainKeyAndNonce != null) {
                        MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
                    } else {
                        try {
                            MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
                        } catch (e: Exception) {
                            // Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
                            if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
                            ""
                        }
                    }

            val normalizedIncomingAttachments =
                    normalizeIncomingAttachments(packet.attachments, plainText)

            // 📝 LOG: Расшифровка успешна
            MessageLogger.logDecryptionSuccess(
                    messageId = messageId,
                    plainTextLength = plainText.length,
                    attachmentsCount = normalizedIncomingAttachments.size
            )

            // Сериализуем attachments в JSON с расшифровкой MESSAGES blob
            val attachmentsJson =
                    serializeAttachmentsWithDecryption(
                            normalizedIncomingAttachments,
                            packet.chachaKey,
                            privateKey,
                            plainKeyAndNonce,
                            groupKey
                    )

            // 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
            processImageAttachments(
                    normalizedIncomingAttachments,
                    packet.chachaKey,
                    privateKey,
                    plainKeyAndNonce,
                    messageId,
                    groupKey
            )

            // 📸 Обрабатываем AVATAR attachments:
            // в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity)
            val avatarOwnerKey =
                    if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
            processAvatarAttachments(
                    normalizedIncomingAttachments,
                    avatarOwnerKey,
                    packet.chachaKey,
                    privateKey,
                    plainKeyAndNonce,
                    groupKey
            )

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

            val storedChachaKey =
                    if (isGroupMessage) {
                        buildStoredGroupKey(groupKey!!, privateKey)
                    } else if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
                        "sync:${packet.aesChachaKey}"
                    } else {
                        packet.chachaKey
                    }

            // Desktop parity (useSynchronize.ts):
            // own messages received via PacketMessage sync are inserted as DELIVERED immediately.
            // WAITING is used only for messages created locally on this device before PacketDelivery.
            val initialDeliveredStatus = DeliveryStatus.DELIVERED.value

            // Создаем entity для кэша и возможной вставки
            val entity =
                    MessageEntity(
                            account = account,
                            fromPublicKey = packet.fromPublicKey,
                            toPublicKey = packet.toPublicKey,
                            content = packet.content,
                            timestamp = normalizedPacketTimestamp,
                            chachaKey = storedChachaKey,
                            read = 0,
                            fromMe = if (isOwnMessage) 1 else 0,
                            delivered = initialDeliveredStatus,
                            messageId = messageId, // 🔥 Используем сгенерированный messageId!
                            plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
                            attachments = attachmentsJson,
                            primaryAttachmentType =
                                    resolvePrimaryAttachmentType(normalizedIncomingAttachments),
                            dialogKey = dialogKey
                    )

            // ✅ Проверяем существование перед вставкой (защита от дубликатов)
            val stillExists = messageDao.messageExists(account, messageId)

            if (!stillExists) {
                // Сохраняем в БД только если сообщения нет
                messageDao.insertMessage(entity)
                upsertSearchIndex(account, entity, plainText)
                MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
            } else {
                MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
            }

            // 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
            dialogDao.updateDialogFromMessages(account, dialogOpponentKey)

            // 🔥 Логируем что записалось в диалог
            val dialog = dialogDao.getDialog(account, dialogOpponentKey)
            MessageLogger.logDialogUpdate(
                    dialogKey = dialogKey,
                    lastMessage = dialog?.lastMessage,
                    unreadCount = dialog?.unreadCount ?: 0
            )

            // 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа.
            // Важно: не форсим повторный запрос на каждый входящий пакет — это создает
            // шторм PacketSearch во время sync и заметно тормозит обработку.
            if (!isGroupDialogKey(dialogOpponentKey)) {
                requestUserInfo(dialogOpponentKey)
            } else {
                applyGroupDisplayNameToDialog(account, dialogOpponentKey)
                val senderKey = packet.fromPublicKey.trim()
                if (senderKey.isNotBlank() &&
                                senderKey != account &&
                                !isGroupDialogKey(senderKey)
                ) {
                    requestUserInfo(senderKey)
                }
            }

            // Обновляем кэш только если сообщение новое
            if (!stillExists) {
                val message = entity.toMessage()
                updateMessageCache(dialogKey, message)
                MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0)

                // 🔔 Уведомляем UI о новом сообщении (emit dialogKey для обновления)
                _newMessageEvents.tryEmit(dialogKey)
            }

            // 📝 LOG: Успешная обработка
            MessageLogger.logReceiveSuccess(messageId, System.currentTimeMillis() - startTime)
            return true
        } catch (e: Exception) {
            // 📝 LOG: Ошибка обработки
            MessageLogger.logDecryptionError(messageId, e)
            protocolClient.addLog(
                    "❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " +
                            "group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " +
                            "aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: "<no-message>"}"
            )
            // Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
            processedMessageIds.remove(messageId)
            e.printStackTrace()
            return false
        }
    }

    /** Обработка подтверждения доставки */
    suspend fun handleDelivery(packet: PacketDelivery) {
        val account = currentAccount ?: return

        MessageLogger.logDeliveryStatus(
                messageId = packet.messageId,
                toPublicKey = packet.toPublicKey,
                status = "DELIVERED"
        )

        val deliveryTimestamp = System.currentTimeMillis()
        messageDao.updateDeliveryStatusAndTimestamp(
                account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
        )

        // Обновляем кэш (status + timestamp)
        val dialogKey = getDialogKey(packet.toPublicKey)
        messageCache[dialogKey]?.let { flow ->
            flow.value = flow.value.map { msg ->
                if (msg.messageId == packet.messageId)
                    msg.copy(deliveryStatus = DeliveryStatus.DELIVERED, timestamp = deliveryTimestamp)
                else msg
            }
        }

        // 🔔 Уведомляем UI о смене статуса доставки
        _deliveryStatusEvents.tryEmit(
                DeliveryStatusUpdate(dialogKey, packet.messageId, DeliveryStatus.DELIVERED)
        )

        // 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageDelivered обновился
        dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
    }

    /**
     * Save an incoming call event locally (for CALLEE side).
     * Creates a message as if received from the peer, with CALL attachment.
     */
    suspend fun saveIncomingCallEvent(fromPublicKey: String, durationSec: Int) {
        val account = currentAccount ?: return
        val privateKey = currentPrivateKey ?: return
        val messageId = java.util.UUID.randomUUID().toString().replace("-", "").take(32)
        val timestamp = System.currentTimeMillis()
        val dialogKey = getDialogKey(fromPublicKey)

        val attId = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
        val attachmentsJson = org.json.JSONArray().apply {
            put(org.json.JSONObject().apply {
                put("id", attId)
                put("type", com.rosetta.messenger.network.AttachmentType.CALL.value)
                put("preview", durationSec.toString())
                put("blob", "")
            })
        }.toString()

        val encryptedPlainMessage = com.rosetta.messenger.crypto.CryptoManager.encryptWithPassword("", privateKey)

        val entity = com.rosetta.messenger.database.MessageEntity(
            account = account,
            fromPublicKey = fromPublicKey,
            toPublicKey = account,
            content = "",
            timestamp = timestamp,
            chachaKey = "",
            read = 1,
            fromMe = 0,
            delivered = com.rosetta.messenger.network.DeliveryStatus.DELIVERED.value,
            messageId = messageId,
            plainMessage = encryptedPlainMessage,
            attachments = attachmentsJson,
            primaryAttachmentType = com.rosetta.messenger.network.AttachmentType.CALL.value,
            dialogKey = dialogKey
        )
        messageDao.insertMessage(entity)
        dialogDao.updateDialogFromMessages(account, fromPublicKey)
        _newMessageEvents.tryEmit(dialogKey)
    }

    /**
     * Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
     * fromPublicKey - кто прочитал (собеседник)
     */
    suspend fun handleRead(packet: PacketRead) {
        val account = currentAccount ?: return
        val fromPublicKey = packet.fromPublicKey.trim()
        val toPublicKey = packet.toPublicKey.trim()
        if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return

        MessageLogger.debug("👁 READ PACKET from=${fromPublicKey.take(16)}..., to=${toPublicKey.take(16)}...")

        // Desktop parity:
        // 1) from=opponent, to=account  -> собеседник прочитал НАШИ сообщения (double check)
        // 2) from=account, to=opponent  -> sync с другого нашего устройства (мы прочитали входящие)
        val isOwnReadSync = fromPublicKey == account

        // Desktop parity (group): from=groupMember, to=groupId -> mark own group messages as read.
        if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
            val dialogKey = getDialogKey(toPublicKey)
            val updatedRows = messageDao.markAllAsRead(account, toPublicKey)

            val readCount =
                    messageCache[dialogKey]?.value?.count {
                        it.isFromMe && !it.isRead
                    } ?: 0
            messageCache[dialogKey]?.let { flow ->
                flow.value =
                        flow.value.map { msg ->
                            if (msg.isFromMe && !msg.isRead) {
                                msg.copy(
                                        isRead = true,
                                        deliveryStatus =
                                                if (
                                                        msg.deliveryStatus == DeliveryStatus.DELIVERED ||
                                                                msg.deliveryStatus == DeliveryStatus.READ
                                                ) {
                                                    DeliveryStatus.READ
                                                } else {
                                                    msg.deliveryStatus
                                                }
                                )
                            } else {
                                msg
                            }
                        }
            }

            if (updatedRows > 0) {
                _deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
            }
            MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = minOf(readCount, updatedRows))
            dialogDao.updateDialogFromMessages(account, toPublicKey)
            return
        }

        val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
        if (opponentKey.isBlank()) return

        val dialogKey = getDialogKey(opponentKey)

        if (isOwnReadSync) {
            // Sync read from another own device: mark incoming messages as read.
            messageDao.markDialogAsRead(account, dialogKey)
            messageCache[dialogKey]?.let { flow ->
                flow.value =
                        flow.value.map { msg ->
                            if (!msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
                        }
            }
            dialogDao.updateDialogFromMessages(account, opponentKey)
            return
        }

        // Opponent read our outgoing messages.
        val updatedRows = messageDao.markAllAsRead(account, opponentKey)

        val readCount =
                messageCache[dialogKey]?.value?.count {
                    it.isFromMe && !it.isRead
                } ?: 0
        messageCache[dialogKey]?.let { flow ->
            flow.value =
                    flow.value.map { msg ->
                        if (msg.isFromMe && !msg.isRead) {
                            msg.copy(
                                    isRead = true,
                                    deliveryStatus =
                                            if (
                                                    msg.deliveryStatus == DeliveryStatus.DELIVERED ||
                                                            msg.deliveryStatus == DeliveryStatus.READ
                                            ) {
                                                DeliveryStatus.READ
                                            } else {
                                                msg.deliveryStatus
                                            }
                            )
                        } else {
                            msg
                        }
                    }
        }

        // Notify current dialog UI only when there are real DB read updates.
        if (updatedRows > 0) {
            _deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
        }

        MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = minOf(readCount, updatedRows))
        dialogDao.updateDialogFromMessages(account, opponentKey)
    }

    /**
     * Отметить диалог как прочитанный 🔥 После обновления messages обновляем диалог через
     * updateDialogFromMessages 📁 SAVED MESSAGES: Использует специальный метод для saved messages
     */
    suspend fun markDialogAsRead(opponentKey: String) {
        val account = currentAccount ?: return
        val dialogKey = getDialogKey(opponentKey)

        // Отмечаем сообщения как прочитанные
        messageDao.markDialogAsRead(account, dialogKey)

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

    /** Отправить уведомление "печатает" 📁 Для Saved Messages - не отправляем */
    fun sendTyping(toPublicKey: String) {
        val account = currentAccount ?: return
        val privateKey = currentPrivateKey ?: return

        // 📁 Для Saved Messages - не отправляем typing
        if (account == toPublicKey) {
            return
        }

        scope.launch {
            val packet =
                    PacketTyping().apply {
                        this.fromPublicKey = account
                        this.toPublicKey = toPublicKey
                        this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
                    }
            protocolClient.send(packet)
        }
    }

    /** Создать или обновить диалог */
    suspend fun createOrUpdateDialog(
            opponentKey: String,
            title: String = "",
            username: String = "",
            verified: Boolean = false
    ) {
        val account = currentAccount ?: return

        val existing = dialogDao.getDialog(account, opponentKey)
        if (existing != null) {
            dialogDao.updateOpponentInfo(
                    account,
                    opponentKey,
                    title,
                    username,
                    if (verified) 1 else 0
            )
        } else {
            dialogDao.insertDialog(
                    DialogEntity(
                            account = account,
                            opponentKey = opponentKey,
                            opponentTitle = title,
                            opponentUsername = username,
                            verified = if (verified) 1 else 0
                    )
            )
        }
    }

    // ===============================
    // Retry WAITING messages on reconnect
    // ===============================

    /**
     * Desktop parity: resend messages stuck in WAITING status.
     *
     * On Android, if the app is killed while a message is being sent, it stays in DB
     * with delivered=WAITING forever. Desktop has _packetQueue (in-memory) but desktop
     * apps are rarely force-killed. On Android this is critical.
     *
     * Messages older than MESSAGE_MAX_TIME_TO_DELIVERED_MS are marked as ERROR instead
     * of being retried (desktop: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80s).
     */
    suspend fun retryWaitingMessages() {
        val account = currentAccount ?: return
        val privateKey = currentPrivateKey ?: return
        val now = System.currentTimeMillis()

        // Desktop parity recovery:
        // historically, own synced direct messages ("sync:*" chacha_key) could be saved as WAITING/ERROR
        // on Android and then incorrectly shown with failed status.
        // Desktop stores them as DELIVERED from the beginning.
        val syncedOpponentsWithWrongStatus =
                messageDao.getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account)
        val normalizedSyncedCount = messageDao.markSyncedOwnMessagesAsDelivered(account)
        if (normalizedSyncedCount > 0) {
            syncedOpponentsWithWrongStatus.forEach { opponentKey ->
                runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
            }
            if (BuildConfig.DEBUG) android.util.Log.i(
                    "MessageRepository",
                    "✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
            )
        }

        // Mark expired messages as ERROR (older than 80 seconds)
        val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
        if (expiredCount > 0) {
            if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
        }

        // Get remaining WAITING messages (younger than 80s)
        val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
        if (waitingMessages.isEmpty()) return

        if (BuildConfig.DEBUG) android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")

        for (entity in waitingMessages) {
            // Skip saved messages (should not happen, but guard)
            if (entity.fromPublicKey == entity.toPublicKey) continue

            try {
                // The message is already saved in DB with encrypted content and chachaKey.
                // We can re-send the PacketMessage directly using stored fields.
                val aesChachaKeyValue = if (entity.chachaKey.startsWith("sync:")) {
                    entity.chachaKey.removePrefix("sync:")
                } else if (entity.chachaKey.startsWith("group:")) {
                    entity.chachaKey.removePrefix("group:")
                } else {
                    // Re-generate aesChachaKey from the stored chachaKey + privateKey.
                    // The chachaKey in DB is the ECC-encrypted key for the recipient.
                    // We need to decrypt it, then re-encrypt with our private key for self-sync.
                    try {
                        val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(entity.chachaKey, privateKey)
                        CryptoManager.encryptWithPassword(
                            String(plainKeyAndNonce, Charsets.ISO_8859_1),
                            privateKey
                        )
                    } catch (e: Exception) {
                        if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
                        ""
                    }
                }

                val packet = PacketMessage().apply {
                    this.fromPublicKey = account
                    this.toPublicKey = entity.toPublicKey
                    this.content = entity.content
                    this.chachaKey =
                            if (
                                    entity.chachaKey.startsWith("sync:") ||
                                            entity.chachaKey.startsWith("group:")
                            ) {
                                ""
                            } else {
                                entity.chachaKey
                            }
                    this.aesChachaKey = aesChachaKeyValue
                    this.timestamp = entity.timestamp
                    this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
                    this.messageId = entity.messageId
                    this.attachments = emptyList() // Attachments already uploaded, tags are in content
                }

                // iOS parity: use retry mechanism for reconnect-resent messages too
                protocolClient.sendMessageWithRetry(packet)
                if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
            } catch (e: Exception) {
                if (BuildConfig.DEBUG) android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
                // Mark as ERROR if retry fails
                messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
                val dialogKey = getDialogKey(entity.toPublicKey)
                updateMessageStatus(dialogKey, entity.messageId, DeliveryStatus.ERROR)
                _deliveryStatusEvents.tryEmit(
                        DeliveryStatusUpdate(dialogKey, entity.messageId, DeliveryStatus.ERROR)
                )
            }
        }
    }

    // ===============================
    // Private helpers
    // ===============================

    /**
     * Получить ключ диалога для группировки сообщений 📁 SAVED MESSAGES: Для saved messages
     * (account == opponentKey) возвращает просто account
     */
    fun getDialogKey(opponentKey: String): String {
        val account = currentAccount ?: return opponentKey
        if (isGroupDialogKey(opponentKey)) {
            return opponentKey.trim()
        }
        // Для saved messages dialog_key = просто publicKey
        if (account == opponentKey) {
            return account
        }
        // Для обычных диалогов - сортируем ключи
        return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$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 toGroupDialogPublicKey(value: String): String {
        val groupId = normalizeGroupId(value)
        return if (groupId.isBlank()) value.trim() else "#group:$groupId"
    }

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

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

    private suspend fun resolveGroupKeyForDialog(
            account: String,
            privateKey: String,
            groupDialogPublicKey: String
    ): String? {
        val groupId = normalizeGroupId(groupDialogPublicKey)
        if (groupId.isBlank()) return null
        val group = groupDao.getGroup(account, groupId) ?: return null
        return CryptoManager.decryptWithPassword(group.key, privateKey)
    }

    private suspend fun applyGroupDisplayNameToDialog(account: String, dialogKey: String) {
        val groupId = normalizeGroupId(dialogKey)
        if (groupId.isBlank()) return
        val group = groupDao.getGroup(account, groupId) ?: return
        dialogDao.updateOpponentDisplayName(
                account = account,
                opponentKey = dialogKey,
                title = group.title,
                username = group.description
        )
    }

    private fun updateMessageCache(dialogKey: String, message: Message) {
        messageCache[dialogKey]?.let { flow ->
            val currentList = flow.value.toMutableList()
            val existingIndex = currentList.indexOfFirst { it.messageId == message.messageId }
            if (existingIndex >= 0) {
                currentList[existingIndex] = message
            } else {
                currentList.add(message)
                currentList.sortWith(compareBy<Message>({ it.timestamp }, { it.messageId }))
            }
            flow.value = currentList
        }
    }

    /**
     * Runtime API to update delivery status (e.g., marking as ERROR on retry timeout).
     */
    suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
        val account = currentAccount ?: return
        messageDao.updateDeliveryStatus(account, messageId, status.value)
        updateMessageStatus(dialogKey, messageId, status)
        _deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, messageId, status))
    }

    private fun updateMessageStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
        messageCache[dialogKey]?.let { flow ->
            flow.value =
                    flow.value.map { msg ->
                        if (msg.messageId == messageId) msg.copy(deliveryStatus = status) else msg
                    }
        }
    }

    private fun dotCenterIfNeeded(value: String, maxLength: Int, side: Int): String {
        if (value.length <= maxLength) return value
        val safeSide = side.coerceAtLeast(1)
        if (safeSide * 2 >= value.length) return value
        return value.take(safeSide) + "..." + value.takeLast(safeSide)
    }

    private suspend fun updateDialog(
            opponentKey: String,
            lastMessage: String,
            timestamp: Long,
            incrementUnread: Boolean = false
    ) {
        val account = currentAccount ?: return
        val privateKey = currentPrivateKey ?: return

        try {
            // 🔥 КРИТИЧНО: Сначала считаем реальное количество непрочитанных из messages
            val unreadCount = messageDao.getUnreadCountForDialog(account, opponentKey)

            // 🔒 Шифруем lastMessage
            val encryptedLastMessage = CryptoManager.encryptWithPassword(lastMessage, privateKey)

            // Проверяем существует ли диалог
            val existing = dialogDao.getDialog(account, opponentKey)

            if (existing != null) {
                // Обновляем существующий диалог
                dialogDao.updateLastMessage(account, opponentKey, encryptedLastMessage, timestamp)
                dialogDao.updateUnreadCount(account, opponentKey, unreadCount)
            } else {
                // Создаем новый диалог
                dialogDao.insertDialog(
                        DialogEntity(
                                account = account,
                                opponentKey = opponentKey,
                                lastMessage = encryptedLastMessage,
                                lastMessageTimestamp = timestamp,
                                unreadCount = unreadCount,
                                hasContent = if (encryptedLastMessage.isNotBlank()) 1 else 0
                        )
                )
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    /** Обновить онлайн-статус пользователя в диалоге */
    suspend fun updateOnlineStatus(publicKey: String, isOnline: Boolean) {
        val account = currentAccount ?: return

        // Обновляем статус в базе
        dialogDao.updateOnlineStatus(
                account = account,
                opponentKey = publicKey,
                isOnline = if (isOnline) 1 else 0,
                lastSeen = if (!isOnline) System.currentTimeMillis() else 0
        )
    }

    /** Наблюдать за онлайн статусом пользователя */
    fun observeUserOnlineStatus(publicKey: String): Flow<Pair<Boolean, Long>> {
        val account = currentAccount ?: return flowOf(false to 0L)

        return dialogDao.observeOnlineStatus(account, publicKey).map { info ->
            if (info != null) {
                (info.isOnline == 1) to info.lastSeen
            } else {
                false to 0L
            }
        }
    }

    /**
     * Обновить информацию о пользователе в диалоге (имя, username, verified) Вызывается когда
     * приходит ответ на PacketSearch
     */
    suspend fun updateDialogUserInfo(
            publicKey: String,
            title: String,
            username: String,
            verified: Int
    ) {
        val account = currentAccount ?: return

        // Проверяем существует ли диалог с этим пользователем
        val existing = dialogDao.getDialog(account, publicKey)
        if (existing != null) {
            dialogDao.updateOpponentInfo(account, publicKey, title, username, verified)

            // 🔥 Проверим что данные сохранились
            val updated = dialogDao.getDialog(account, publicKey)
        } else {}
    }

    /**
     * Очистить кэш сообщений для конкретного диалога 🔥 ВАЖНО: Устанавливаем пустой список, а не
     * просто удаляем - чтобы подписчики Flow увидели что диалог пуст
     */
    fun clearDialogCache(opponentKey: String) {
        val dialogKey = getDialogKey(opponentKey)

        // Сначала устанавливаем пустой список чтобы все подписчики увидели
        messageCache[dialogKey]?.value = emptyList()

        // Затем удаляем из кэша
        messageCache.remove(dialogKey)
    }

    /**
     * Desktop parity: clear the one-shot guard so that names can be re-requested
     * after reconnect / sync. Desktop's useUserInformation re-fires on every render;
     * on Android we clear the guard after each sync cycle instead.
     */
    fun clearUserInfoRequestCache() {
        requestedUserInfoKeys.clear()
    }

    /**
     * Desktop parity: after sync, resolve names for ALL dialogs that still have
     * an empty / placeholder title. Desktop does this per-component via useUserInformation;
     * we batch it here for efficiency.
     */
    suspend fun requestMissingUserInfo() {
        val account = currentAccount ?: return
        val privateKey = currentPrivateKey ?: return
        val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)

        // Query dialogs with empty or placeholder titles
        val dialogs = dialogDao.getDialogsWithEmptyTitle(account)
        for (dialog in dialogs) {
            // Skip self (Saved Messages)
            if (dialog.opponentKey == account) continue
            if (isGroupDialogKey(dialog.opponentKey)) continue
            // Skip if already requested in this cycle
            if (requestedUserInfoKeys.contains(dialog.opponentKey)) continue
            requestedUserInfoKeys.add(dialog.opponentKey)

            val packet = PacketSearch().apply {
                this.privateKey = privateKeyHash
                this.search = dialog.opponentKey
            }
            protocolClient.send(packet)
            // Small delay to avoid flooding the server with search requests
            kotlinx.coroutines.delay(50)
        }
    }

    /**
     * Force-request user info, bypassing the one-shot guard.
     * Use when opening a dialog to ensure the name/username is fresh.
     */
    fun forceRequestUserInfo(publicKey: String) {
        if (isGroupDialogKey(publicKey)) return
        requestedUserInfoKeys.remove(publicKey)
        requestUserInfo(publicKey)
    }

    /**
     * Запросить информацию о пользователе с сервера 🔥 Защита от бесконечных запросов - каждый ключ
     * запрашивается только один раз
     */
    fun requestUserInfo(publicKey: String) {
        val privateKey = currentPrivateKey ?: return
        if (isGroupDialogKey(publicKey)) return

        // 🔥 Не запрашиваем если уже запрашивали
        if (requestedUserInfoKeys.contains(publicKey)) {
            return
        }
        requestedUserInfoKeys.add(publicKey)

        scope.launch {
            val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
            val packet =
                    PacketSearch().apply {
                        this.privateKey = privateKeyHash
                        this.search = publicKey
                    }
            protocolClient.send(packet)
        }
    }

    // Extension functions
    private fun MessageEntity.toMessage(): Message {
        // 🔓 Расшифровываем plainMessage с использованием приватного ключа
        val privateKey = currentPrivateKey
        val decryptedText =
                if (privateKey != null && plainMessage.isNotEmpty()) {
                    try {
                        CryptoManager.decryptWithPassword(plainMessage, privateKey)
                                ?: safePlainMessageFallback(plainMessage)
                    } catch (e: Exception) {
                        safePlainMessageFallback(plainMessage)
                    }
                } else {
                    safePlainMessageFallback(plainMessage)
                }

        return Message(
                id = id,
                messageId = messageId,
                fromPublicKey = fromPublicKey,
                toPublicKey = toPublicKey,
                content = decryptedText, // 🔓 Расшифрованный текст
                timestamp = timestamp,
                isFromMe = fromMe == 1,
                isRead = read == 1,
                deliveryStatus = DeliveryStatus.fromInt(delivered),
                replyToMessageId = replyToMessageId
        )
    }

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

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

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

    private fun DialogEntity.toDialog() =
            Dialog(
                    opponentKey = opponentKey,
                    opponentTitle = opponentTitle,
                    opponentUsername = opponentUsername,
                    lastMessage = lastMessage,
                    lastMessageTimestamp = lastMessageTimestamp,
                    unreadCount = unreadCount,
                    isOnline = isOnline == 1,
                    lastSeen = lastSeen,
                    verified = verified == 1
            )

    /**
     * Сериализация attachments в JSON 🔥 ВАЖНО: blob НЕ сохраняется в БД (как в desktop) Только
     * метаданные: id, type, preview, width, height blob скачивается с CDN по id при показе
     */
    private fun serializeAttachments(attachments: List<MessageAttachment>): String {
        if (attachments.isEmpty()) return "[]"

        val jsonArray = JSONArray()
        for (attachment in attachments) {
            val jsonObj =
                    JSONObject().apply {
                        put("id", attachment.id)
                        // 🔥 blob НЕ сохраняем в БД - скачивается с CDN по id
                        put("blob", "")
                        put("type", attachment.type.value)
                        put("preview", attachment.preview)
                        put("width", attachment.width)
                        put("height", attachment.height)
                        put("localUri", attachment.localUri)
                        put("transportTag", attachment.transportTag)
                        put("transportServer", attachment.transportServer)
                    }
            jsonArray.put(jsonObj)
        }
        return jsonArray.toString()
    }

    private fun resolvePrimaryAttachmentType(attachments: List<MessageAttachment>): Int {
        if (attachments.isEmpty()) return -1
        return attachments.first().type.value
    }

    /**
     * Desktop иногда присылает attachment звонка с некорректным type при поврежденном/пограничном
     * пакете (в UI это превращается в пустой пузырь). Для attachment-only сообщения мягко
     * нормализуем такой кейс к CALL.
     */
    private fun normalizeIncomingAttachments(
            attachments: List<MessageAttachment>,
            plainText: String
    ): List<MessageAttachment> {
        if (attachments.isEmpty() || plainText.isNotBlank() || attachments.size != 1) {
            return attachments
        }

        val first = attachments.first()
        if (!isLikelyCallAttachment(first, plainText)) {
            return attachments
        }

        return when (first.type) {
            AttachmentType.CALL -> attachments
            else -> {
                MessageLogger.debug(
                        "📥 ATTACHMENT FIXUP: coerced ${first.type} -> CALL for ${first.id.take(8)}..."
                )
                listOf(first.copy(type = AttachmentType.CALL))
            }
        }
    }

    private fun isLikelyCallAttachment(attachment: MessageAttachment, plainText: String): Boolean {
        if (plainText.isNotBlank()) return false
        if (attachment.blob.isNotBlank()) return false
        if (attachment.width > 0 || attachment.height > 0) return false

        val preview = attachment.preview.trim()
        if (preview.isEmpty()) return true

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

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

    private suspend fun upsertSearchIndex(account: String, entity: MessageEntity, plainText: String) {
        val opponentKey =
                if (entity.fromMe == 1) entity.toPublicKey.trim() else entity.fromPublicKey.trim()
        val normalized = plainText.lowercase(Locale.ROOT)
        searchIndexDao.upsert(
                listOf(
                        MessageSearchIndexEntity(
                                account = account,
                                messageId = entity.messageId,
                                dialogKey = entity.dialogKey,
                                opponentKey = opponentKey,
                                timestamp = entity.timestamp,
                                fromMe = entity.fromMe,
                                plainText = plainText,
                                plainTextNormalized = normalized
                        )
                )
        )
    }

    /**
     * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
     * получении attachment с типом AVATAR - сохраняем в avatar_cache
     */
    private suspend fun processAvatarAttachments(
            attachments: List<MessageAttachment>,
            avatarOwnerKey: String,
            encryptedKey: String,
            privateKey: String,
            plainKeyAndNonce: ByteArray? = null,
            groupKey: String? = null
    ) {

        for (attachment in attachments) {

            if (attachment.type == AttachmentType.AVATAR && attachment.blob.isNotEmpty()) {
                try {

                    // 1. Расшифровываем blob с ChaCha ключом сообщения
                    val decryptedBlob =
                            if (groupKey != null) {
                                decryptWithGroupKeyCompat(attachment.blob, groupKey)
                            } else {
                                plainKeyAndNonce?.let {
                                    MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
                                }
                                        ?: MessageCrypto.decryptAttachmentBlob(
                                                attachment.blob,
                                                encryptedKey,
                                                privateKey
                                        )
                            }

                    if (decryptedBlob != null) {
                        // 2. Сохраняем аватар в кэш
                        val filePath =
                                AvatarFileManager.saveAvatar(context, decryptedBlob, avatarOwnerKey)

                        val entity =
                                AvatarCacheEntity(
                                        publicKey = avatarOwnerKey,
                                        avatar = filePath,
                                        timestamp = System.currentTimeMillis()
                                )
                        avatarDao.insertAvatar(entity)

                        // 3. Очищаем старые аватары (оставляем последние 5)
                        avatarDao.deleteOldAvatars(avatarOwnerKey, 5)
                    } else {}
                } catch (e: Exception) {}
            }
        }
    }

    /**
     * 🖼️ Обработка IMAGE attachments - сохранение в файл (как в desktop) Desktop сохраняет:
     * writeFile(`m/${md5(attachment.id + publicKey)}`, encryptedBlob) Файлы (FILE тип) НЕ
     * сохраняются - они слишком большие, загружаются с CDN
     */
    private fun processImageAttachments(
            attachments: List<MessageAttachment>,
            encryptedKey: String,
            privateKey: String,
            plainKeyAndNonce: ByteArray? = null,
            messageId: String = "",
            groupKey: String? = null
    ) {
        val publicKey = currentAccount ?: return

        for (attachment in attachments) {
            // Сохраняем только IMAGE, не FILE (файлы загружаются с CDN при необходимости)
            if (attachment.type == AttachmentType.IMAGE && attachment.blob.isNotEmpty()) {
                MessageLogger.logPhotoDecryptStart(messageId, attachment.id, attachment.blob.length)
                try {

                    // 1. Расшифровываем blob с ChaCha ключом сообщения
                    val decryptedBlob =
                            if (groupKey != null) {
                                decryptWithGroupKeyCompat(attachment.blob, groupKey)
                            } else {
                                plainKeyAndNonce?.let {
                                    MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
                                }
                                        ?: MessageCrypto.decryptAttachmentBlob(
                                                attachment.blob,
                                                encryptedKey,
                                                privateKey
                                        )
                            }

                    if (decryptedBlob != null) {
                        // 2. Сохраняем в файл (как в desktop)
                        val saved =
                                AttachmentFileManager.saveAttachment(
                                        context = context,
                                        blob = decryptedBlob,
                                        attachmentId = attachment.id,
                                        publicKey = publicKey,
                                        privateKey = privateKey
                                )

                        if (saved) {
                            MessageLogger.logPhotoDecryptSuccess(messageId, attachment.id, true)
                        } else {
                            MessageLogger.logPhotoSaveFailed(messageId, attachment.id)
                        }
                    } else {
                        MessageLogger.logPhotoDecryptFailed(messageId, attachment.id)
                    }
                } catch (e: Exception) {
                    MessageLogger.logPhotoDecryptError(messageId, attachment.id, e)
                }
            }
        }
    }

    /**
     * Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД Для MESSAGES типа:
     * 1. Расшифровываем blob с ChaCha ключом сообщения
     * 2. Re-encrypt с приватным ключом (как в Desktop Архиве)
     * 3. Сохраняем зашифрованный blob в БД
     */
    private fun serializeAttachmentsWithDecryption(
            attachments: List<MessageAttachment>,
            encryptedKey: String,
            privateKey: String,
            plainKeyAndNonce: ByteArray? = null,
            groupKey: String? = null
    ): String {
        if (attachments.isEmpty()) return "[]"

        val jsonArray = JSONArray()
        for (attachment in attachments) {
            val jsonObj = JSONObject()

            // ⚠️ НЕ сохраняем blob для IMAGE/FILE - слишком большие (SQLite CursorWindow 2MB limit)
            // Только MESSAGES (reply) сохраняем - они небольшие
            if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) {
                try {
                    // 1. Расшифровываем с ChaCha ключом сообщения
                    val decryptedBlob =
                            if (groupKey != null) {
                                decryptWithGroupKeyCompat(attachment.blob, groupKey)
                            } else {
                                plainKeyAndNonce?.let {
                                    MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
                                }
                                        ?: MessageCrypto.decryptAttachmentBlob(
                                                attachment.blob,
                                                encryptedKey,
                                                privateKey
                                        )
                            }

                    if (decryptedBlob != null) {
                        // 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
                        val reEncryptedBlob =
                                CryptoManager.encryptWithPassword(decryptedBlob, privateKey)

                        // 3. Сохраняем ЗАШИФРОВАННЫЙ blob в БД (только для MESSAGES - они
                        // небольшие)
                        jsonObj.put("id", attachment.id)
                        jsonObj.put("blob", reEncryptedBlob) // 🔒 Зашифрован приватным ключом!
                        jsonObj.put("type", attachment.type.value)
                        jsonObj.put("preview", attachment.preview)
                        jsonObj.put("width", attachment.width)
                        jsonObj.put("height", attachment.height)
                        jsonObj.put("localUri", attachment.localUri)
                        jsonObj.put("transportTag", attachment.transportTag)
                        jsonObj.put("transportServer", attachment.transportServer)
                    } else {
                        // Fallback - пустой blob для IMAGE/FILE
                        jsonObj.put("id", attachment.id)
                        jsonObj.put("blob", "")
                        jsonObj.put("type", attachment.type.value)
                        jsonObj.put("preview", attachment.preview)
                        jsonObj.put("width", attachment.width)
                        jsonObj.put("height", attachment.height)
                        jsonObj.put("localUri", attachment.localUri)
                        jsonObj.put("transportTag", attachment.transportTag)
                        jsonObj.put("transportServer", attachment.transportServer)
                    }
                } catch (e: Exception) {
                    // Fallback - пустой blob
                    jsonObj.put("id", attachment.id)
                    jsonObj.put("blob", "")
                    jsonObj.put("type", attachment.type.value)
                    jsonObj.put("preview", attachment.preview)
                    jsonObj.put("width", attachment.width)
                    jsonObj.put("height", attachment.height)
                    jsonObj.put("localUri", attachment.localUri)
                    jsonObj.put("transportTag", attachment.transportTag)
                    jsonObj.put("transportServer", attachment.transportServer)
                }
            } else {
                // Для IMAGE/FILE - НЕ сохраняем blob (пустой)
                jsonObj.put("id", attachment.id)
                jsonObj.put("blob", "") // Пустой blob для IMAGE/FILE
                jsonObj.put("type", attachment.type.value)
                jsonObj.put("preview", attachment.preview)
                jsonObj.put("width", attachment.width)
                jsonObj.put("height", attachment.height)
                jsonObj.put("localUri", attachment.localUri)
                jsonObj.put("transportTag", attachment.transportTag)
                jsonObj.put("transportServer", attachment.transportServer)
            }

            jsonArray.put(jsonObj)
        }
        return jsonArray.toString()
    }

    /**
     * Desktop parity for group attachment blobs:
     * old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes).
     */
    private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? {
        if (encryptedBlob.isBlank() || groupKey.isBlank()) return null

        val rawAttempt = runCatching {
            CryptoManager.decryptWithPassword(encryptedBlob, groupKey)
        }.getOrNull()
        if (rawAttempt != null) return rawAttempt

        val hexKey =
                groupKey.toByteArray(Charsets.ISO_8859_1)
                        .joinToString("") { "%02x".format(it.toInt() and 0xff) }
        if (hexKey == groupKey) return null

        return runCatching {
            CryptoManager.decryptWithPassword(encryptedBlob, hexKey)
        }.getOrNull()
    }
}
