package com.rosetta.messenger.ui.chats

import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

/** UI модель диалога с расшифрованным lastMessage */
@Immutable
data class DialogUiModel(
        val id: Long,
        val account: String,
        val opponentKey: String,
        val opponentTitle: String,
        val opponentUsername: String,
        val lastMessage: String, // 🔓 Расшифрованный текст
        val lastMessageTimestamp: Long,
        val unreadCount: Int,
        val isOnline: Int,
        val lastSeen: Long,
        val verified: Int,
        val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
        val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
        val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
        val lastMessageRead: Int = 0, // Прочитано (0/1)
        val lastMessageAttachmentType: String? =
                null, // 📎 Тип attachment: "Photo", "File", или null
        val lastMessageSenderPrefix: String? = null, // 👥 Для групп: "You" или имя отправителя
        val lastMessageSenderKey: String? = null, // 👥 Для групп: public key отправителя
        val draftText: String? = null // 📝 Черновик сообщения (как в Telegram)
)

/**
 * 🔥 Комбинированное состояние чатов для атомарного обновления UI Это предотвращает "дергание"
 * когда dialogs и requests обновляются независимо
 */
@Immutable
data class ChatsUiState(
        val dialogs: List<DialogUiModel> = emptyList(),
        val requests: List<DialogUiModel> = emptyList(),
        val requestsCount: Int = 0
) {
    val isEmpty: Boolean
        get() = dialogs.isEmpty() && requestsCount == 0
    val hasContent: Boolean
        get() = dialogs.isNotEmpty() || requestsCount > 0
}

/** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */
@HiltViewModel
class ChatsListViewModel @Inject constructor(
        private val app: Application,
        private val protocolGateway: ProtocolGateway,
        private val messageRepository: MessageRepository,
        private val groupRepository: GroupRepository
) : ViewModel() {

    private val database = RosettaDatabase.getDatabase(app)
    private val dialogDao = database.dialogDao()

    private var currentAccount: String = ""
    private var currentPrivateKey: String? = null

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

    // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
    private val subscribedOnlineKeys = mutableSetOf<String>()

    private data class DialogUiCacheEntry(
            val signature: Int,
            val model: DialogUiModel
    )

    private val dialogsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
    private val requestsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()

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

    // Список диалогов с расшифрованными сообщениями
    private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
    val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()

    // Список requests (запросы от новых пользователей)
    private val _requests = MutableStateFlow<List<DialogUiModel>>(emptyList())
    val requests: StateFlow<List<DialogUiModel>> = _requests.asStateFlow()

    // Количество requests
    private val _requestsCount = MutableStateFlow(0)
    val requestsCount: StateFlow<Int> = _requestsCount.asStateFlow()
    val syncInProgress: StateFlow<Boolean> = protocolGateway.syncInProgress

    // Заблокированные пользователи (реактивный Set из Room Flow)
    private val _blockedUsers = MutableStateFlow<Set<String>>(emptySet())
    val blockedUsers: StateFlow<Set<String>> = _blockedUsers.asStateFlow()

    // 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
    // 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях
    // 📝 Комбинируем с drafts для показа черновиков в списке чатов
    val chatsState: StateFlow<ChatsUiState> =
            combine(_dialogs, _requests, _requestsCount, DraftManager.drafts) { dialogs, requests, count, drafts ->
                        // 📝 Обогащаем диалоги черновиками
                        val dialogsWithDrafts = if (drafts.isEmpty()) dialogs else {
                            dialogs.map { dialog ->
                                val draft = drafts[dialog.opponentKey]
                                if (draft != null) dialog.copy(draftText = draft) else dialog
                            }
                        }
                        ChatsUiState(dialogsWithDrafts, requests, count)
                    }
                    .distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
                    .stateIn(
                            viewModelScope,
                            SharingStarted
                                    .Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу
                            // начинаем следить
                            ChatsUiState()
                    )

    // Загрузка
    // Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван.
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    private val loadingFailSafeTimeoutMs = 4500L

    private val TAG = "ChatsListVM"
    private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
    private val groupJoinedMarker = "\$a=Group joined"
    private val groupCreatedMarker = "\$a=Group created"
    private val attachmentTagUuidRegex =
            Regex(
                    "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
            )

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

    private fun rosettaDev1Log(msg: String) {
        runCatching {
            val appContext = app
            val dir = java.io.File(appContext.filesDir, "crash_reports")
            if (!dir.exists()) dir.mkdirs()
            val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
            java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n")
        }
    }

    private data class GroupLastSenderInfo(
            val senderPrefix: String,
            val senderKey: String
    )

    private suspend fun resolveGroupLastSenderInfo(
            dialog: com.rosetta.messenger.database.DialogEntity
    ): GroupLastSenderInfo? {
        if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null

        val senderKey =
                dialog.lastSenderKey.trim().ifBlank {
                    if (dialog.lastMessageFromMe == 1) currentAccount else ""
                }

        if (senderKey.isBlank()) return null
        if (senderKey == currentAccount) {
            return GroupLastSenderInfo(senderPrefix = "You", senderKey = senderKey)
        }

        val senderName = resolveKnownDisplayName(senderKey)
        if (senderName == null) {
            loadUserInfoForDialog(senderKey)
        }
        return GroupLastSenderInfo(
                senderPrefix = senderName ?: senderKey.take(7),
                senderKey = senderKey
        )
    }

    private suspend fun resolveKnownDisplayName(publicKey: String): String? {
        if (publicKey.isBlank()) return null
        if (publicKey == currentAccount) return "You"

        val dialogName =
                try {
                    val userDialog = dialogDao.getDialog(currentAccount, publicKey)
                    userDialog?.let {
                        extractDisplayName(
                                title = it.opponentTitle,
                                username = it.opponentUsername,
                                publicKey = publicKey
                        )
                    }
                } catch (_: Exception) {
                    null
                }
        if (!dialogName.isNullOrBlank()) return dialogName

        val cached = protocolGateway.getCachedUserName(publicKey).orEmpty().trim()
        if (cached.isNotBlank() && cached != publicKey) {
            return cached
        }

        return null
    }

    private fun extractDisplayName(title: String, username: String, publicKey: String): String? {
        val normalizedTitle = title.trim()
        if (normalizedTitle.isNotEmpty() &&
                        normalizedTitle != publicKey &&
                        normalizedTitle != publicKey.take(7) &&
                        normalizedTitle != publicKey.take(8)
        ) {
            return normalizedTitle
        }

        val normalizedUsername = username.trim()
        if (normalizedUsername.isNotEmpty() &&
                        normalizedUsername != publicKey &&
                        normalizedUsername != publicKey.take(7) &&
                        normalizedUsername != publicKey.take(8)
        ) {
            return normalizedUsername
        }

        return null
    }

    private fun buildDialogSignature(dialog: com.rosetta.messenger.database.DialogEntity): Int {
        var result = dialog.id.hashCode()
        result = 31 * result + dialog.account.hashCode()
        result = 31 * result + dialog.opponentKey.hashCode()
        result = 31 * result + dialog.opponentTitle.hashCode()
        result = 31 * result + dialog.opponentUsername.hashCode()
        result = 31 * result + dialog.lastMessage.hashCode()
        result = 31 * result + dialog.lastMessageTimestamp.hashCode()
        result = 31 * result + dialog.unreadCount
        result = 31 * result + dialog.isOnline
        result = 31 * result + dialog.lastSeen.hashCode()
        result = 31 * result + dialog.verified
        result = 31 * result + dialog.lastMessageFromMe
        result = 31 * result + dialog.lastMessageDelivered
        result = 31 * result + dialog.lastMessageRead
        result = 31 * result + dialog.lastMessageAttachments.hashCode()
        return result
    }

    private fun shouldRequestDialogUserInfo(
            dialog: com.rosetta.messenger.database.DialogEntity,
            isRequestsFlow: Boolean
    ): Boolean {
        val title = dialog.opponentTitle
        if (isRequestsFlow) {
            return title.isEmpty() || title == dialog.opponentKey
        }
        return title.isEmpty() ||
                title == dialog.opponentKey ||
                title == dialog.opponentKey.take(7) ||
                title == dialog.opponentKey.take(8)
    }

    private fun normalizeDialogList(dialogs: List<DialogUiModel>): List<DialogUiModel> {
        if (dialogs.isEmpty()) return emptyList()
        val deduped = LinkedHashMap<String, DialogUiModel>(dialogs.size)
        dialogs.forEach { dialog ->
            if (!deduped.containsKey(dialog.opponentKey)) {
                deduped[dialog.opponentKey] = dialog
            }
        }
        return deduped.values.sortedByDescending { it.lastMessageTimestamp }
    }

    /**
     * During sync we keep list stable only when there are truly no visible dialog changes.
     * This lets local sends/new system dialogs appear immediately even if sync is active.
     */
    private fun canFreezeDialogsDuringSync(
            dialogsList: List<com.rosetta.messenger.database.DialogEntity>
    ): Boolean {
        val currentDialogs = _dialogs.value
        if (currentDialogs.isEmpty()) return false
        if (dialogsList.size != currentDialogs.size) return false

        val currentByKey = currentDialogs.associateBy { it.opponentKey }
        return dialogsList.all { entity ->
            val current = currentByKey[entity.opponentKey] ?: return@all false
            current.lastMessageTimestamp == entity.lastMessageTimestamp &&
                    current.unreadCount == entity.unreadCount &&
                    current.isOnline == entity.isOnline &&
                    current.lastSeen == entity.lastSeen &&
                    current.verified == entity.verified &&
                    current.opponentTitle == entity.opponentTitle &&
                    current.opponentUsername == entity.opponentUsername &&
                    current.lastMessageFromMe == entity.lastMessageFromMe &&
                    current.lastMessageDelivered == entity.lastMessageDelivered &&
                    current.lastMessageRead == entity.lastMessageRead &&
                    current.lastMessage == entity.lastMessage &&
                    current.lastMessageAttachmentType ==
                            resolveAttachmentType(
                                    attachmentType = entity.lastMessageAttachmentType,
                                    decryptedLastMessage = current.lastMessage,
                                    lastMessageAttachments = entity.lastMessageAttachments
                            )
        }
    }

    private suspend fun mapDialogListIncremental(
            dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
            privateKey: String,
            cache: LinkedHashMap<String, DialogUiCacheEntry>,
            isRequestsFlow: Boolean
    ): List<DialogUiModel> {
        return withContext(Dispatchers.Default) {
            val activeKeys = HashSet<String>(dialogsList.size)
            val mapped = ArrayList<DialogUiModel>(dialogsList.size)

            dialogsList.forEach { dialog ->
                val cacheKey = dialog.opponentKey
                activeKeys.add(cacheKey)

                val signature = buildDialogSignature(dialog)
                val cached = cache[cacheKey]
                if (cached != null && cached.signature == signature) {
                    mapped.add(cached.model)
                    return@forEach
                }

                val isSavedMessages = dialog.account == dialog.opponentKey
                if (!isSavedMessages && shouldRequestDialogUserInfo(dialog, isRequestsFlow)) {
                    if (isRequestsFlow) {
                        loadUserInfoForRequest(dialog.opponentKey)
                    } else {
                        loadUserInfoForDialog(dialog.opponentKey)
                    }
                }

                val decryptedLastMessage =
                        decryptDialogPreview(
                                encryptedLastMessage = dialog.lastMessage,
                                privateKey = privateKey
                        )
                val attachmentType =
                        resolveAttachmentType(
                                attachmentType = dialog.lastMessageAttachmentType,
                                decryptedLastMessage = decryptedLastMessage,
                                lastMessageAttachments = dialog.lastMessageAttachments
                        )
                val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog)

                val model =
                        DialogUiModel(
                                id = dialog.id,
                                account = dialog.account,
                                opponentKey = dialog.opponentKey,
                                opponentTitle = dialog.opponentTitle,
                                opponentUsername = dialog.opponentUsername,
                                lastMessage = decryptedLastMessage,
                                lastMessageTimestamp = dialog.lastMessageTimestamp,
                                unreadCount = dialog.unreadCount,
                                isOnline = dialog.isOnline,
                                lastSeen = dialog.lastSeen,
                                verified = dialog.verified,
                                isSavedMessages = isSavedMessages,
                                lastMessageFromMe = dialog.lastMessageFromMe,
                                lastMessageDelivered = dialog.lastMessageDelivered,
                                lastMessageRead = dialog.lastMessageRead,
                                lastMessageAttachmentType = attachmentType,
                                lastMessageSenderPrefix = groupLastSenderInfo?.senderPrefix,
                                lastMessageSenderKey = groupLastSenderInfo?.senderKey
                        )

                cache[cacheKey] = DialogUiCacheEntry(signature = signature, model = model)
                mapped.add(model)
            }

            cache.keys.retainAll(activeKeys)
            normalizeDialogList(mapped)
        }
    }

    /** Установить текущий аккаунт и загрузить диалоги */
    fun setAccount(publicKey: String, privateKey: String) {
        val resolvedPrivateKey =
                when {
                    privateKey.isNotBlank() -> privateKey
                    currentAccount == publicKey -> currentPrivateKey.orEmpty()
                    else -> ""
                }

        if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
            // 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
            if (_isLoading.value) {
                _isLoading.value = false
            }
            loadingFailSafeJob?.cancel()
            return
        }

        // 🔥 Показываем skeleton пока данные грузятся
        _isLoading.value = true
        loadingFailSafeJob?.cancel()
        loadingFailSafeJob =
                viewModelScope.launch {
                    delay(loadingFailSafeTimeoutMs)
                    if (_isLoading.value) {
                        _isLoading.value = false
                        android.util.Log.w(
                                TAG,
                                "Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..."
                        )
                        rosettaDev1Log(
                                "Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs"
                        )
                    }
                }

        // 🔥 Очищаем кэш запрошенных user info при смене аккаунта
        requestedUserInfoKeys.clear()
        subscribedOnlineKeys.clear()
        dialogsUiCache.clear()
        requestsUiCache.clear()

        // 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
        // чтобы избежать показа сообщений с неправильным isOutgoing
        ChatViewModel.clearAllDialogCache()

        // Отменяем старые подписки от предыдущего аккаунта
        accountSubscriptionsJob?.cancel()

        currentAccount = publicKey
        currentPrivateKey = resolvedPrivateKey

        // � Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
        DraftManager.setAccount(publicKey)

        // �🔥 Очищаем устаревшие данные от предыдущего аккаунта
        _dialogs.value = emptyList()
        _requests.value = emptyList()
        _requestsCount.value = 0

        // 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
        if (resolvedPrivateKey.isNotEmpty()) {
            viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
        }

        // Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
        accountSubscriptionsJob = viewModelScope.launch {

        // 🟢 Сбрасываем устаревшие online-флаги из прошлого сеанса.
        // Актуальные статусы придут сразу после PacketOnlineSubscribe.
        withContext(Dispatchers.IO) {
            runCatching { dialogDao.clearOnlineStatuses(publicKey) }
        }

        // Подписываемся на обычные диалоги
        @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
        launch {
            dialogDao
                    .getDialogsFlow(publicKey)
                    .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
                    .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
                    .combine(protocolGateway.syncInProgress) { dialogsList, syncing ->
                        dialogsList to syncing
                    }
                    .mapLatest { (dialogsList, syncing) ->
                        // Keep list stable during sync only when the snapshot is effectively unchanged.
                        // Otherwise (new message/dialog/status) update immediately.
                        if (syncing && canFreezeDialogsDuringSync(dialogsList)) {
                            null
                        } else {
                            mapDialogListIncremental(
                                    dialogsList = dialogsList,
                                    privateKey = resolvedPrivateKey,
                                    cache = dialogsUiCache,
                                    isRequestsFlow = false
                            )
                        }
                    }
                    .filterNotNull()
                    .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
                    .catch { e ->
                        android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e)
                        rosettaDev1Log("Dialogs flow failed: ${e.message}")
                        if (_isLoading.value) _isLoading.value = false
                        emit(emptyList())
                    }
                    .collect { decryptedDialogs ->
                        _dialogs.value = decryptedDialogs
                        // 🚀 Убираем skeleton после первой загрузки
                        if (_isLoading.value) {
                            _isLoading.value = false
                            loadingFailSafeJob?.cancel()
                        }

                        // 🟢 Подписываемся на онлайн-статусы всех собеседников
                        // 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
                        // статус
                        val opponentsToSubscribe =
                                decryptedDialogs.filter { !it.isSavedMessages }.map {
                                    it.opponentKey
                                }
                        subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
                    }
        }

        // 📬 Подписываемся на requests (запросы от новых пользователей)
        @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
        launch {
            dialogDao
                    .getRequestsFlow(publicKey)
                    .flowOn(Dispatchers.IO)
                    .debounce(100) // 🚀 Батчим быстрые обновления
                    .combine(protocolGateway.syncInProgress) { requestsList, syncing ->
                        requestsList to syncing
                    }
                    .mapLatest { (requestsList, syncing) ->
                        if (syncing) {
                            emptyList()
                        } else {
                            mapDialogListIncremental(
                                    dialogsList = requestsList,
                                    privateKey = resolvedPrivateKey,
                                    cache = requestsUiCache,
                                    isRequestsFlow = true
                            )
                        }
                    }
                    .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
                    .collect { decryptedRequests -> _requests.value = decryptedRequests }
        }

        // 📊 Подписываемся на количество requests
        launch {
            dialogDao
                    .getRequestsCountFlow(publicKey)
                    .flowOn(Dispatchers.IO)
                    .combine(protocolGateway.syncInProgress) { count, syncing ->
                        if (syncing) 0 else count
                    }
                    .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
                    .collect { count -> _requestsCount.value = count }
        }

        // 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
        // blockUser()/unblockUser()
        launch {
            database.blacklistDao()
                    .getBlockedUsers(publicKey)
                    .flowOn(Dispatchers.IO)
                    .map { entities -> entities.map { it.publicKey }.toSet() }
                    .distinctUntilChanged()
                    .collect { blockedSet -> _blockedUsers.value = blockedSet }
        }

        // Desktop parity: when sync finishes (syncInProgress transitions true → false),
        // clear the one-shot requestedUserInfoKeys guard so the dialog-list .map{} block
        // can re-trigger loadUserInfoForDialog() on the next Room emission for any
        // dialogs that still have empty titles.
        launch {
            var wasSyncing = false
            protocolGateway.syncInProgress.collect { syncing ->
                if (wasSyncing && !syncing) {
                    requestedUserInfoKeys.clear()
                }
                wasSyncing = syncing
            }
        }

        } // end accountSubscriptionsJob

        accountSubscriptionsJob?.invokeOnCompletion { cause ->
            if (cause != null && _isLoading.value) {
                _isLoading.value = false
                loadingFailSafeJob?.cancel()
                android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause)
                rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}")
            }
        }
    }

    fun forceStopLoading(reason: String) {
        if (_isLoading.value) {
            _isLoading.value = false
            loadingFailSafeJob?.cancel()
            android.util.Log.w(TAG, "forceStopLoading: $reason")
            rosettaDev1Log("forceStopLoading: $reason")
        }
    }

    /**
     * 🟢 Подписаться на онлайн-статусы всех собеседников 🔥 Фильтруем уже подписанные ключи чтобы
     * избежать бесконечного цикла
     */
    private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
        if (opponentKeys.isEmpty()) return
        if (privateKey.isBlank()) return

        // 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
        val newKeys =
                opponentKeys.filter { key ->
                    !subscribedOnlineKeys.contains(key) && !isGroupKey(key)
                }
        if (newKeys.isEmpty()) return // Все уже подписаны

        // Добавляем в Set ДО отправки пакета чтобы избежать race condition
        subscribedOnlineKeys.addAll(newKeys)

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

                val packet =
                        PacketOnlineSubscribe().apply {
                            this.privateKey = privateKeyHash
                            newKeys.forEach { key -> addPublicKey(key) }
                        }

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

    private fun decryptDialogPreview(encryptedLastMessage: String, privateKey: String): String {
        if (encryptedLastMessage.isEmpty()) return ""

        if (privateKey.isEmpty()) {
            val plainCandidate =
                    if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
            return formatPreviewText(plainCandidate)
        }

        val decrypted =
                try {
                    CryptoManager.decryptWithPassword(encryptedLastMessage, privateKey)
                } catch (e: Exception) {
                    null
                }

        val resolved = when {
            decrypted != null -> decrypted
            isLikelyEncryptedPayload(encryptedLastMessage) -> ""
            else -> encryptedLastMessage
        }
        return formatPreviewText(resolved)
    }

    private fun formatPreviewText(value: String): String {
        if (value.isBlank()) return value
        val normalized = value.trim()
        return when {
            groupInviteRegex.matches(normalized) -> "Group Invite"
            normalized == groupJoinedMarker -> "You joined the group"
            normalized == groupCreatedMarker -> "Group created"
            else -> value
        }
    }

    private fun resolveAttachmentType(
            attachmentType: Int,
            decryptedLastMessage: String,
            lastMessageAttachments: String
    ): String? {
        val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
        val effectiveType =
                if (attachmentType >= 0) attachmentType
                else inferAttachmentTypeFromJson(lastMessageAttachments)

        return when (effectiveType) {
            0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
            1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
            2 -> "File" // AttachmentType.FILE = 2
            3 -> "Avatar" // AttachmentType.AVATAR = 3
            4 -> "Call" // AttachmentType.CALL = 4
            5 -> "Voice message" // AttachmentType.VOICE = 5
            6 -> "Video message" // AttachmentType.VIDEO_CIRCLE = 6
            else -> if (inferredCall) "Call" else null
        }
    }

    private fun inferAttachmentTypeFromJson(rawAttachments: String): Int {
        if (rawAttachments.isBlank() || rawAttachments == "[]") return -1
        return try {
            val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
            if (attachments.length() <= 0) return -1
            val first = attachments.optJSONObject(0) ?: return -1
            val parsedType = parseAttachmentTypeValue(first.opt("type"))
            if (parsedType in 0..6) {
                parsedType
            } else {
                inferLegacyAttachmentTypeFromJson(first)
            }
        } catch (_: Throwable) {
            -1
        }
    }

    private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
        if (rawAttachments.isBlank() || rawAttachments == "[]") return false
        return try {
            val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return false
            if (attachments.length() != 1) return false
            val first = attachments.optJSONObject(0) ?: return false

            val typeValue = parseAttachmentTypeValue(first.opt("type"))
            if (typeValue == 4) return true

            val preview = first.optString("preview", "").trim()
            isLikelyCallAttachmentPreview(preview)
        } catch (_: Throwable) {
            false
        }
    }

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

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

        if (isLikelyMessagesAttachmentPayload(preview = preview, blob = blob)) return 1
        if (blob.isBlank() && width <= 0 && height <= 0 && isLikelyCallAttachmentPreview(preview)) return 4
        if (isLikelyVideoCircleAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 6
        if (isLikelyVoiceAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 5
        if (width > 0 || height > 0) return 0
        if (isLikelyFileAttachmentPreview(preview) || transportTag.isNotBlank()) return 2
        return -1
    }

    private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
        if (preview.isBlank()) return false
        val normalized = preview.trim()
        val tail = normalized.substringAfterLast("::", normalized).trim()
        if (tail.toIntOrNull() != null) return true
        return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
                .containsMatchIn(normalized)
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        runCatching { JSONArray(normalized) }.getOrNull()?.let { return it }
        runCatching { JSONObject(normalized) }.getOrNull()?.let { return JSONArray().put(it) }

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

        return null
    }

    private fun isLikelyEncryptedPayload(value: String): Boolean {
        if (value.startsWith("CHNK:")) return true

        val parts = value.split(":", limit = 2)
        if (parts.size != 2) return false

        fun isBase64Like(part: String): Boolean {
            if (part.length < 12) return false
            return part.all { ch ->
                ch.isLetterOrDigit() ||
                        ch == '+' || ch == '/' || ch == '=' ||
                        ch == '-' || ch == '_'
            }
        }

        return isBase64Like(parts[0]) && isBase64Like(parts[1])
    }

    /**
     * Создать или обновить диалог после отправки/получения сообщения 🔥 Используем
     * updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует
     * специальный метод для saved messages
     */
    suspend fun upsertDialog(
            opponentKey: String,
            opponentTitle: String,
            opponentUsername: String = "",
            lastMessage: String,
            timestamp: Long,
            verified: Int = 0,
            isOnline: Int = 0
    ) {
        if (currentAccount.isEmpty()) return

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

            // Обновляем информацию о собеседнике если есть
            if (opponentTitle.isNotEmpty()) {
                dialogDao.updateOpponentInfo(
                        currentAccount,
                        opponentKey,
                        opponentTitle,
                        opponentUsername,
                        verified
                )
            }
        } catch (e: Exception) {}
    }

    /** Конвертировать DialogUiModel в SearchUser для навигации */
    fun dialogToSearchUser(dialog: DialogUiModel): SearchUser {
        return SearchUser(
                title = dialog.opponentTitle,
                username = dialog.opponentUsername,
                publicKey = dialog.opponentKey,
                verified = dialog.verified,
                online = dialog.isOnline
        )
    }

    /**
     * Удалить диалог и все сообщения с собеседником 🔥 ПОЛНОСТЬЮ очищает все данные - диалог,
     * сообщения, кэш
     */
    suspend fun deleteDialog(opponentKey: String) {
        if (currentAccount.isEmpty()) return

        try {
            // 🚀 Сразу обновляем UI - удаляем диалог из локального списка
            _dialogs.value = _dialogs.value.filter { it.opponentKey != opponentKey }
            // 🔥 Также удаляем из requests!
            _requests.value = _requests.value.filter { it.opponentKey != opponentKey }
            // 🔥 Обновляем счетчик requests
            _requestsCount.value = _requests.value.size
            dialogsUiCache.remove(opponentKey)
            requestsUiCache.remove(opponentKey)

            // Вычисляем правильный dialog_key
            val dialogKey =
                    if (currentAccount == opponentKey) {
                        currentAccount // 📁 Saved Messages
                    } else if (currentAccount < opponentKey) {
                        "$currentAccount:$opponentKey"
                    } else {
                        "$opponentKey:$currentAccount"
                    }

            // 🗑️ 1. Очищаем ВСЕ кэши сообщений
            messageRepository.clearDialogCache(opponentKey)
            // 🗑️ Очищаем кэш ChatViewModel
            ChatViewModel.clearCacheForOpponent(opponentKey)

            // 🗑️ 2. Проверяем сколько сообщений в БД до удаления
            val messageCountBefore =
                    database.messageDao().getMessageCount(currentAccount, dialogKey)

            // 🗑️ 3. Удаляем все сообщения из диалога по dialog_key
            val deletedByDialogKey =
                    database.messageDao()
                            .deleteDialog(account = currentAccount, dialogKey = dialogKey)

            // 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения)
            val deletedBetweenUsers =
                    database.messageDao()
                            .deleteMessagesBetweenUsers(
                                    account = currentAccount,
                                    user1 = opponentKey,
                                    user2 = currentAccount
                            )

            // 🗑️ 5. Проверяем сколько сообщений осталось
            val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey)

            // 🗑️ 6. Удаляем диалог из таблицы dialogs
            database.dialogDao().deleteDialog(account = currentAccount, opponentKey = opponentKey)

            // 🗑️ 7. Проверяем что диалог удален
            val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey)
        } catch (e: Exception) {
            // В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление)
            // Flow обновится автоматически из БД
        }
    }

    /** Выйти из группы и удалить локальный групповой диалог */
    suspend fun leaveGroup(groupPublicKey: String): Boolean {
        if (currentAccount.isEmpty()) return false
        if (!isGroupKey(groupPublicKey)) return false

        return try {
            val left = groupRepository.leaveGroup(currentAccount, groupPublicKey)
            if (left) {
                _dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
                _requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
                _requestsCount.value = _requests.value.size
                dialogsUiCache.remove(groupPublicKey)
                requestsUiCache.remove(groupPublicKey)
                messageRepository.clearDialogCache(groupPublicKey)
                ChatViewModel.clearCacheForOpponent(groupPublicKey)
            }
            left
        } catch (_: Exception) {
            false
        }
    }

    /** Заблокировать пользователя */
    suspend fun blockUser(publicKey: String) {
        if (currentAccount.isEmpty()) return

        try {
            database.blacklistDao()
                    .blockUser(
                            com.rosetta.messenger.database.BlacklistEntity(
                                    publicKey = publicKey,
                                    account = currentAccount
                            )
                    )
        } catch (e: Exception) {}
    }

    /** Разблокировать пользователя */
    suspend fun unblockUser(publicKey: String) {
        if (currentAccount.isEmpty()) return

        try {
            database.blacklistDao().unblockUser(publicKey, currentAccount)
        } catch (e: Exception) {}
    }

    /**
     * 📬 Загрузить информацию о пользователе для request 📁 НЕ загружаем для Saved Messages (свой
     * publicKey)
     */
    private fun loadUserInfoForRequest(publicKey: String) {
        loadUserInfoForDialog(publicKey)
    }

    /**
     * 🔥 Загрузить информацию о пользователе для диалога 📁 НЕ загружаем для Saved Messages (свой
     * publicKey)
     */
    private fun loadUserInfoForDialog(publicKey: String) {
        // 📁 Не запрашиваем информацию о самом себе (Saved Messages)
        if (publicKey == currentAccount || isGroupKey(publicKey)) {
            return
        }

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

        viewModelScope.launch(Dispatchers.IO) {
            try {
                val sharedPrefs =
                        app
                                .getSharedPreferences("rosetta", Application.MODE_PRIVATE)
                val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: ""

                if (currentUserPrivateKey.isEmpty()) return@launch

                // 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo
                val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)

                // Запрашиваем информацию о пользователе с сервера
                val packet =
                        PacketSearch().apply {
                            this.privateKey = privateKeyHash
                            this.search = publicKey
                        }
                protocolGateway.send(packet)
            } catch (e: Exception) {}
        }
    }

    /** Проверить заблокирован ли пользователь */
    suspend fun isUserBlocked(publicKey: String): Boolean {
        if (currentAccount.isEmpty()) return false

        return try {
            database.blacklistDao().isUserBlocked(publicKey, currentAccount)
        } catch (e: Exception) {
            false
        }
    }
}
