import Foundation
import Combine
import GRDB

/// Account-scoped message store backed by SQLite (GRDB).
/// Android parity: `MessageRepository.kt` + Room DAO queries.
@MainActor
final class MessageRepository: ObservableObject {
    static let shared = MessageRepository()

    /// In-memory cache of messages for active dialogs. UI binds to this.
    @Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:]
    /// dialogKey → set of senderPublicKeys for active typing indicators.
    @Published private(set) var typingDialogs: [String: Set<String>] = [:]

    private var activeDialogs: Set<String> = []
    /// Dialogs that are currently eligible for interactive read:
    /// screen is visible and list is at the bottom (Telegram-like behavior).
    private var readEligibleDialogs: Set<String> = []
    /// Per-sender typing reset timers. Key: "dialogKey::senderKey"
    private var typingResetTasks: [String: Task<Void, Never>] = [:]
    private var currentAccount: String = ""

    /// Android parity: private key for encrypting message text at rest.
    /// Android: `CryptoManager.encryptWithPassword(plainText, privateKey)` → `plain_message` column.
    /// Set from SessionManager.startSession().
    private var privateKey: String = ""

    /// Android parity: in-memory LRU dedup cache (max 1000 entries).
    /// O(1) check BEFORE SQL query. Protects against duplicate packets and race conditions.
    private var processedMessageIds = LRUSet<String>(maxSize: 1000)

    /// Debounced cache refresh — batches multiple refreshes during sync.
    /// Android parity: ChatViewModel debounces incoming messages at 100ms.
    private var pendingCacheRefresh: Set<String> = []
    private var cacheRefreshTask: Task<Void, Never>?

    /// Guards against duplicate async initial loads for the same dialog.
    private var initialLoadInFlight: Set<String> = []

    /// Page size for initial message loading. Android: PAGE_SIZE = 30.
    static let pageSize = 50

    /// Max messages per dialog in memory cache.
    /// Increased from 500 to 3000 for pagination support.
    /// With .equatable() cells, only ~15 visible cells render — RAM impact is ~600 KB.
    /// Telegram parity: keep ~200 messages in memory (sliding window).
    /// Telegram uses ~90 (fillCount=44 × 2 directions). We use 200 for scroll buffer.
    static let maxCacheSize = 200

    private var db: DatabaseManager { DatabaseManager.shared }

    private init() {}

    // MARK: - Lifecycle

    /// Android parity: `MessageRepository.initialize(publicKey, privateKey)`.
    func bootstrap(accountPublicKey: String, storagePassword: String) async {
        let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !account.isEmpty else {
            reset()
            return
        }

        if currentAccount == account, !messagesByDialog.isEmpty { return }

        currentAccount = account
        privateKey = storagePassword  // privateKeyHex used as encryption password
        activeDialogs.removeAll()
        typingDialogs.removeAll()
        for task in typingResetTasks.values { task.cancel() }
        typingResetTasks.removeAll()
        messagesByDialog.removeAll()
        processedMessageIds.removeAll()
        pendingCacheRefresh.removeAll()
        cacheRefreshTask?.cancel()
    }

    // MARK: - Dialog State

    func messages(for dialogKey: String) -> [ChatMessage] {
        if let cached = messagesByDialog[dialogKey] { return cached }
        guard !currentAccount.isEmpty else { return [] }
        // Async initial load: return [] immediately, decrypt on background thread.
        // Combine pipeline in ChatDetailViewModel picks up the async result.
        // Skeleton shows while isLoading = initial.isEmpty.
        triggerAsyncInitialLoad(for: dialogKey)
        return []
    }

    /// Trigger background decryption for initial dialog load.
    /// DB read on MainActor (~5ms), AES decrypt on Task.detached (~150ms OFF main thread).
    private func triggerAsyncInitialLoad(for dialogKey: String) {
        guard !initialLoadInFlight.contains(dialogKey) else { return }
        initialLoadInFlight.insert(dialogKey)

        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        let pk = privateKey
        let account = currentAccount

        Task { @MainActor [weak self] in
            guard let self else { return }
            let records: [MessageRecord]
            do {
                records = try db.read { db in
                    let descRequest = MessageRecord
                        .filter(MessageRecord.Columns.account == account &&
                                MessageRecord.Columns.dialogKey == dbDialogKey)
                        .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
                        .limit(Self.pageSize)
                    let recs = try descRequest.fetchAll(db)
                    return Array(recs.reversed())
                }
            } catch {
                self.initialLoadInFlight.remove(dialogKey)
                return
            }

            let messages = await Task.detached(priority: .userInitiated) {
                Self.decryptRecordsBatch(records, privateKey: pk)
            }.value

            self.messagesByDialog[dialogKey] = messages
            self.initialLoadInFlight.remove(dialogKey)
        }
    }

    /// Load older messages for pagination (scroll-to-load-more).
    /// Uses a composite cursor `(timestamp, messageId)` to avoid gaps when multiple
    /// messages share the same timestamp.
    /// Sync version — used by tests. For UI pagination use `loadOlderMessagesAsync`.
    func loadOlderMessages(
        for dialogKey: String,
        beforeTimestamp: Int64,
        beforeMessageId: String,
        limit: Int = 50
    ) -> [ChatMessage] {
        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        do {
            let records = try db.read { db in
                try Self.fetchOlderRecords(
                    db: db, account: currentAccount, dbDialogKey: dbDialogKey,
                    beforeTimestamp: beforeTimestamp, beforeMessageId: beforeMessageId, limit: limit
                )
            }
            let older = records.reversed().map { decryptRecord($0) }
            Self.prependToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: older)
            return older
        } catch {
            print("[DB] loadOlderMessages error: \(error)")
            return []
        }
    }

    /// Async version: DB read on MainActor (~5ms), decryption on background thread (~150ms).
    /// Eliminates main thread freeze during scroll pagination.
    func loadOlderMessagesAsync(
        for dialogKey: String,
        beforeTimestamp: Int64,
        beforeMessageId: String,
        limit: Int = 50
    ) async -> [ChatMessage] {
        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        let pk = privateKey

        // Step 1: DB read on MainActor (GRDB DatabasePool.read is fast, ~5ms for 50 rows)
        let records: [MessageRecord]
        do {
            records = try db.read { db in
                try Self.fetchOlderRecords(
                    db: db, account: currentAccount, dbDialogKey: dbDialogKey,
                    beforeTimestamp: beforeTimestamp, beforeMessageId: beforeMessageId, limit: limit
                )
            }
        } catch {
            print("[DB] loadOlderMessagesAsync error: \(error)")
            return []
        }
        let reversed = Array(records.reversed())

        // Step 2: Decrypt on background thread (AES × 50 = ~150ms, OFF main thread)
        let older = await Task.detached(priority: .userInitiated) {
            Self.decryptRecordsBatch(reversed, privateKey: pk)
        }.value

        // Step 3: Cache update on MainActor
        Self.prependToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: older)
        return older
    }

    /// Reverse pagination: load newer messages when scrolling back toward bottom.
    /// Sync version — used by tests. For UI pagination use `loadNewerMessagesAsync`.
    func loadNewerMessages(
        for dialogKey: String,
        afterTimestamp: Int64,
        afterMessageId: String,
        limit: Int = pageSize
    ) -> [ChatMessage] {
        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        do {
            let records = try db.read { db in
                try Self.fetchNewerRecords(
                    db: db, account: currentAccount, dbDialogKey: dbDialogKey,
                    afterTimestamp: afterTimestamp, afterMessageId: afterMessageId, limit: limit
                )
            }
            let newer = records.map { decryptRecord($0) }
            Self.appendToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: newer)
            return newer
        } catch {
            print("[DB] loadNewerMessages error: \(error)")
            return []
        }
    }

    /// Async version: DB read on MainActor, decryption on background thread.
    func loadNewerMessagesAsync(
        for dialogKey: String,
        afterTimestamp: Int64,
        afterMessageId: String,
        limit: Int = pageSize
    ) async -> [ChatMessage] {
        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        let pk = privateKey

        let records: [MessageRecord]
        do {
            records = try db.read { db in
                try Self.fetchNewerRecords(
                    db: db, account: currentAccount, dbDialogKey: dbDialogKey,
                    afterTimestamp: afterTimestamp, afterMessageId: afterMessageId, limit: limit
                )
            }
        } catch {
            print("[DB] loadNewerMessagesAsync error: \(error)")
            return []
        }

        let newer = await Task.detached(priority: .userInitiated) {
            Self.decryptRecordsBatch(records, privateKey: pk)
        }.value

        Self.appendToCache(&messagesByDialog, dialogKey: dialogKey, newMessages: newer)
        return newer
    }

    // MARK: - Background Decrypt (nonisolated)

    /// Thread-safe batch decryption for pagination. Runs on background thread.
    /// Skips `persistReDecryptedText` (MainActor DB write) and GroupRepository access.
    /// Re-decrypt persistence happens on next main-thread load if needed.
    nonisolated private static func decryptRecordsBatch(
        _ records: [MessageRecord], privateKey: String
    ) -> [ChatMessage] {
        records.map { record in
            let plainText: String

            if !privateKey.isEmpty {
                if let data = try? CryptoManager.shared.decryptWithPassword(
                    record.text, password: privateKey, requireCompression: true
                ), let decrypted = String(data: data, encoding: .utf8) {
                    plainText = decrypted
                } else if let data = try? CryptoManager.shared.decryptWithPassword(
                    record.text, password: privateKey
                ), let decrypted = String(data: data, encoding: .utf8),
                          !isProbablyEncryptedPayloadNonisolated(decrypted) {
                    plainText = decrypted
                } else {
                    let fallback = safePlainMessageFallbackNonisolated(record.text)
                    if fallback.isEmpty, !record.content.isEmpty, !record.chachaKey.isEmpty {
                        if let (text, _) = try? MessageCrypto.decryptIncomingFull(
                            ciphertext: record.content,
                            encryptedKey: record.chachaKey,
                            myPrivateKeyHex: privateKey
                        ) {
                            plainText = text
                        } else {
                            plainText = fallback
                        }
                    } else {
                        plainText = fallback
                    }
                }
            } else {
                plainText = safePlainMessageFallbackNonisolated(record.text)
            }

            return record.toChatMessage(overrideText: plainText)
        }
    }

    /// Nonisolated version of `safePlainMessageFallback` for background decryption.
    nonisolated private static func safePlainMessageFallbackNonisolated(_ raw: String) -> String {
        if raw.isEmpty { return "" }
        if isProbablyEncryptedPayloadNonisolated(raw) { return "" }
        return raw
    }

    /// Nonisolated version of `isProbablyEncryptedPayload` for background decryption.
    nonisolated private static func isProbablyEncryptedPayloadNonisolated(_ value: String) -> Bool {
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.hasPrefix("CHNK:") { return true }
        let parts = trimmed.components(separatedBy: ":")
        if parts.count == 2 {
            let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
            if parts.allSatisfy({ part in
                part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
            }) { return true }
        }
        if trimmed.count >= 40 {
            let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
            if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
        }
        return false
    }

    // MARK: - Cache Helpers

    private static func prependToCache(
        _ cache: inout [String: [ChatMessage]], dialogKey: String, newMessages: [ChatMessage]
    ) {
        if var cached = cache[dialogKey] {
            let existingIds = Set(cached.map(\.id))
            let filtered = newMessages.filter { !existingIds.contains($0.id) }
            cached = filtered + cached
            if cached.count > maxCacheSize * 3 {
                cached = Array(cached.prefix(maxCacheSize * 2))
            }
            cache[dialogKey] = cached
        }
    }

    private static func appendToCache(
        _ cache: inout [String: [ChatMessage]], dialogKey: String, newMessages: [ChatMessage]
    ) {
        if var cached = cache[dialogKey] {
            let existingIds = Set(cached.map(\.id))
            let filtered = newMessages.filter { !existingIds.contains($0.id) }
            cached = cached + filtered
            if cached.count > maxCacheSize * 3 {
                cached = Array(cached.suffix(maxCacheSize * 2))
            }
            cache[dialogKey] = cached
        }
    }

    // MARK: - DB Query Helpers

    /// Extract SQL queries as static methods so they can be called from GRDB `read` closures.
    /// GRDB `read { db in }` closure runs on the pool's serial queue — NOT MainActor.
    /// These must be `nonisolated` to be callable from that non-isolated context.
    nonisolated private static func fetchOlderRecords(
        db: Database, account: String, dbDialogKey: String,
        beforeTimestamp: Int64, beforeMessageId: String, limit: Int
    ) throws -> [MessageRecord] {
        if beforeMessageId.isEmpty {
            return try MessageRecord
                .filter(
                    MessageRecord.Columns.account == account &&
                    MessageRecord.Columns.dialogKey == dbDialogKey &&
                    MessageRecord.Columns.timestamp < beforeTimestamp
                )
                .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
                .limit(limit)
                .fetchAll(db)
        }
        return try MessageRecord
            .filter(
                MessageRecord.Columns.account == account &&
                MessageRecord.Columns.dialogKey == dbDialogKey &&
                (
                    MessageRecord.Columns.timestamp < beforeTimestamp ||
                    (
                        MessageRecord.Columns.timestamp == beforeTimestamp &&
                        MessageRecord.Columns.messageId < beforeMessageId
                    )
                )
            )
            .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
            .limit(limit)
            .fetchAll(db)
    }

    nonisolated private static func fetchNewerRecords(
        db: Database, account: String, dbDialogKey: String,
        afterTimestamp: Int64, afterMessageId: String, limit: Int
    ) throws -> [MessageRecord] {
        try MessageRecord
            .filter(
                MessageRecord.Columns.account == account &&
                MessageRecord.Columns.dialogKey == dbDialogKey &&
                (
                    MessageRecord.Columns.timestamp > afterTimestamp ||
                    (
                        MessageRecord.Columns.timestamp == afterTimestamp &&
                        MessageRecord.Columns.messageId > afterMessageId
                    )
                )
            )
            .order(MessageRecord.Columns.timestamp.asc, MessageRecord.Columns.messageId.asc)
            .limit(limit)
            .fetchAll(db)
    }

    /// Reload latest messages for a dialog (used when jumping to bottom from detached state).
    /// Smart merge: reuses already-decrypted messages from cache, only decrypts genuinely new ones.
    /// DB read ~5ms + O(n) lookup + decrypt only new = ~10-60ms vs ~600-1000ms old approach.
    func reloadLatest(for dialogKey: String) {
        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        let existingCache = messagesByDialog[dialogKey] ?? []
        let existingById = Dictionary(existingCache.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })

        do {
            let records = try db.read { db in
                let descRequest = MessageRecord
                    .filter(
                        MessageRecord.Columns.account == currentAccount &&
                        MessageRecord.Columns.dialogKey == dbDialogKey
                    )
                    .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
                    .limit(Self.maxCacheSize)
                return Array(try descRequest.fetchAll(db).reversed())
            }
            let messages = records.map { record -> ChatMessage in
                // Reuse already-decrypted message from cache — skip expensive AES
                if let cached = existingById[record.messageId] { return cached }
                return decryptRecord(record)
            }
            messagesByDialog[dialogKey] = messages
        } catch {
            print("[DB] reloadLatest error: \(error)")
        }
    }

    /// Android parity: two-level dedup (in-memory LRU + DB check).
    func hasMessage(_ messageId: String) -> Bool {
        // Level 1: in-memory LRU cache (O(1))
        if processedMessageIds.contains(messageId) { return true }
        // Level 2: database check
        guard !currentAccount.isEmpty else { return false }
        do {
            let exists = try db.read { db in
                try MessageRecord.filter(
                    MessageRecord.Columns.account == currentAccount &&
                    MessageRecord.Columns.messageId == messageId
                ).fetchCount(db) > 0
            }
            if exists { processedMessageIds.insert(messageId) }
            return exists
        } catch { return false }
    }

    func dialogKey(forMessageId messageId: String) -> String? {
        guard !currentAccount.isEmpty else { return nil }
        do {
            return try db.read { db in
                try MessageRecord
                    .select(MessageRecord.Columns.dialogKey)
                    .filter(
                        MessageRecord.Columns.account == currentAccount &&
                        MessageRecord.Columns.messageId == messageId
                    )
                    .fetchOne(db)?
                    .dialogKey
            }
        } catch { return nil }
    }

    /// Ensures a specific message exists in the in-memory cache for a dialog.
    /// Returns `true` if the message was found in SQLite and is now available in cache.
    @discardableResult
    func ensureMessageLoaded(for dialogKey: String, messageId: String) -> Bool {
        guard !currentAccount.isEmpty, !messageId.isEmpty else { return false }
        if messagesByDialog[dialogKey]?.contains(where: { $0.id == messageId }) == true {
            return true
        }

        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        do {
            guard let record = try db.read({ db in
                try MessageRecord
                    .filter(
                        MessageRecord.Columns.account == currentAccount &&
                        MessageRecord.Columns.dialogKey == dbDialogKey &&
                        MessageRecord.Columns.messageId == messageId
                    )
                    .fetchOne(db)
            }) else {
                return false
            }

            let hydrated = decryptRecord(record)
            var cached = messagesByDialog[dialogKey] ?? loadMessagesFromDB(dialogKey: dialogKey, limit: Self.pageSize)
            if !cached.contains(where: { $0.id == messageId }) {
                cached.append(hydrated)
                cached.sort {
                    if $0.timestamp == $1.timestamp {
                        return $0.id < $1.id
                    }
                    return $0.timestamp < $1.timestamp
                }
                if cached.count > Self.maxCacheSize {
                    cached = Array(cached.suffix(Self.maxCacheSize))
                }
                messagesByDialog[dialogKey] = cached
            }

            return true
        } catch {
            print("[DB] ensureMessageLoaded error: \(error)")
            return false
        }
    }

    func isLatestMessage(_ messageId: String, in dialogKey: String) -> Bool {
        messages(for: dialogKey).last?.id == messageId
    }

    func latestIncomingTimestamp(for dialogKey: String, myPublicKey: String) -> Int64? {
        guard !currentAccount.isEmpty else { return nil }
        do {
            return try db.read { db in
                try MessageRecord
                    .select(MessageRecord.Columns.timestamp)
                    .filter(
                        MessageRecord.Columns.account == currentAccount &&
                        MessageRecord.Columns.dialogKey == dialogKey &&
                        MessageRecord.Columns.fromMe == 0
                    )
                    .order(MessageRecord.Columns.timestamp.desc)
                    .fetchOne(db)?
                    .timestamp
            }
        } catch { return nil }
    }

    var hasActiveDialog: Bool { !activeDialogs.isEmpty }

    func isDialogActive(_ dialogKey: String) -> Bool {
        activeDialogs.contains(dialogKey)
    }

    var activeDialogKeys: Set<String> { activeDialogs }

    func setDialogActive(_ dialogKey: String, isActive: Bool) {
        if isActive {
            activeDialogs.insert(dialogKey)
        } else {
            activeDialogs.remove(dialogKey)
            readEligibleDialogs.remove(dialogKey)
            typingDialogs.removeValue(forKey: dialogKey)
            cancelTypingTimers(for: dialogKey)
        }
    }

    /// Sets whether a dialog may perform interactive read actions
    /// (mark incoming as read + send read receipt).
    func setDialogReadEligible(_ dialogKey: String, isEligible: Bool) {
        guard !dialogKey.isEmpty else { return }
        if isEligible {
            // Eligibility only makes sense for active dialogs.
            guard activeDialogs.contains(dialogKey) else { return }
            readEligibleDialogs.insert(dialogKey)
        } else {
            readEligibleDialogs.remove(dialogKey)
        }
    }

    func isDialogReadEligible(_ dialogKey: String) -> Bool {
        readEligibleDialogs.contains(dialogKey)
    }

    /// Clears all read eligibility flags. Called when app enters background
    /// to prevent stale eligibility from marking messages as read during
    /// background sync or premature foreground resume.
    func clearAllReadEligibility() {
        readEligibleDialogs.removeAll()
    }

    // MARK: - Message Updates

    func upsertFromMessagePacket(
        _ packet: PacketMessage,
        myPublicKey: String,
        decryptedText: String,
        attachmentPassword: String? = nil,
        fromSync: Bool = false,
        dialogIdentityOverride: String? = nil
    ) {
        PerformanceLogger.shared.track("message.upsert")
        let fromMe = packet.fromPublicKey == myPublicKey
        let opponentKey: String = {
            if let override = dialogIdentityOverride?.trimmingCharacters(in: .whitespacesAndNewlines),
               !override.isEmpty {
                return override
            }
            return fromMe ? packet.toPublicKey : packet.fromPublicKey
        }()
        let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
        let timestamp = normalizeTimestamp(packet.timestamp)
        let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
        // Telegram-like read policy: incoming messages become read only when
        // dialog is explicitly eligible (visible + scrolled to bottom).
        let incomingRead = !fromMe && readEligibleDialogs.contains(opponentKey)
        let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered)

        // Add to LRU dedup cache
        processedMessageIds.insert(messageId)

        // Android parity: encrypt plaintext with private key for local storage.
        // Android: `encryptWithPassword(plainText, privateKey)` → `plain_message` column.
        // If encryption fails, store plaintext as fallback.
        let storedText: String
        if !privateKey.isEmpty,
           let enc = try? CryptoManager.shared.encryptWithPassword(Data(decryptedText.utf8), password: privateKey) {
            storedText = enc
        } else {
            storedText = decryptedText
        }

        let encoder = JSONEncoder()
        let attachmentsJSON: String
        if let data = try? encoder.encode(packet.attachments),
           let str = String(data: data, encoding: .utf8) {
            attachmentsJSON = str
        } else {
            attachmentsJSON = "[]"
        }

        do {
            try db.writeSync { db in
                // Check if message exists
                if let existing = try MessageRecord.filter(
                    MessageRecord.Columns.account == myPublicKey &&
                    MessageRecord.Columns.messageId == messageId
                ).fetchOne(db) {
                    // Protect existing attachments from being wiped during sync.
                    // Android parity: never replace non-empty attachments with empty.
                    let shouldPreserveAttachments: Bool = {
                        guard existing.attachments != "[]", !existing.attachments.isEmpty else { return false }
                        // Never wipe existing attachments with empty data
                        if attachmentsJSON == "[]" { return true }
                        // Protect decrypted .messages blobs from encrypted sync copies
                        if existing.attachments.contains("\"type\":1") || existing.attachments.contains("\"type\": 1") {
                            let existingHasDecrypted = existing.attachments.contains("\"message_id\"")
                            let newHasDecrypted = attachmentsJSON.contains("\"message_id\"")
                            if existingHasDecrypted && !newHasDecrypted { return true }
                        }
                        return false
                    }()

                    let finalAttachments = shouldPreserveAttachments ? existing.attachments : attachmentsJSON


                    // Update existing message (store encrypted text)
                    try db.execute(
                        sql: """
                            UPDATE messages SET content = COALESCE(NULLIF(?, ''), content),
                                chacha_key = COALESCE(NULLIF(?, ''), chacha_key),
                                text = ?, plain_message = ?, timestamp = ?, attachments = ?,
                                attachment_password = COALESCE(?, attachment_password),
                                delivery_status = CASE
                                    WHEN from_me = 1 AND delivery_status = 2 THEN ?
                                    ELSE delivery_status
                                END,
                                is_read = CASE WHEN ? = 1 THEN 1 ELSE is_read END
                            WHERE account = ? AND message_id = ?
                            """,
                        arguments: [
                            packet.content, packet.chachaKey,
                            storedText, storedText, timestamp, finalAttachments,
                            attachmentPassword,
                            fromSync ? DeliveryStatus.delivered.rawValue : DeliveryStatus.waiting.rawValue,
                            incomingRead ? 1 : 0,
                            myPublicKey, messageId
                        ]
                    )
                } else {
                    // Android/Desktop parity: is_read means different things per direction:
                    // - Incoming (from_me=0): "I have read this message" (true if dialog active)
                    // - Outgoing (from_me=1): "Opponent has read this message" (always starts false)
                    let isRead = incomingRead
                    var record = MessageRecord(
                        id: nil,
                        account: myPublicKey,
                        fromPublicKey: packet.fromPublicKey,
                        toPublicKey: packet.toPublicKey,
                        content: packet.content,
                        chachaKey: packet.chachaKey,
                        text: storedText,
                        plainMessage: storedText,
                        timestamp: timestamp,
                        isRead: isRead ? 1 : 0,
                        readAlias: isRead ? 1 : 0,
                        fromMe: fromMe ? 1 : 0,
                        deliveryStatus: outgoingStatus.rawValue,
                        deliveredAlias: outgoingStatus.rawValue,
                        messageId: messageId,
                        replyToMessageId: nil,
                        dialogKey: dialogKey,
                        attachments: attachmentsJSON,
                        attachmentPassword: attachmentPassword
                    )
                    try record.insert(db)
                }
            }
        } catch {
            // Retry once — transient GRDB errors (SQLITE_BUSY, lock contention)
            // should not permanently lose a message.
            print("[DB] upsert error (will retry): \(error)")
            do {
                try db.writeSync { db in
                    var record = MessageRecord(
                        id: nil,
                        account: myPublicKey,
                        fromPublicKey: packet.fromPublicKey,
                        toPublicKey: packet.toPublicKey,
                        content: packet.content,
                        chachaKey: packet.chachaKey,
                        text: storedText,
                        plainMessage: storedText,
                        timestamp: timestamp,
                        isRead: incomingRead ? 1 : 0,
                        readAlias: incomingRead ? 1 : 0,
                        fromMe: fromMe ? 1 : 0,
                        deliveryStatus: outgoingStatus.rawValue,
                        deliveredAlias: outgoingStatus.rawValue,
                        messageId: messageId,
                        replyToMessageId: nil,
                        dialogKey: dialogKey,
                        attachments: attachmentsJSON,
                        attachmentPassword: attachmentPassword
                    )
                    try record.insert(db, onConflict: .replace)
                }
            } catch {
                print("[DB] upsert retry FAILED — message may be lost: \(error)")
            }
        }

        // Outgoing user-sent messages: in-memory append (bypass 100ms debounce + full re-decrypt).
        // Construct ChatMessage from already-decrypted text — 0ms vs 150ms refreshDialogCache.
        if fromMe && !fromSync {
            let chatMessage = ChatMessage(
                id: messageId,
                fromPublicKey: packet.fromPublicKey,
                toPublicKey: packet.toPublicKey,
                text: decryptedText,
                timestamp: timestamp,
                deliveryStatus: .waiting,
                isRead: false,
                attachments: packet.attachments,
                attachmentPassword: attachmentPassword
            )
            if var cached = messagesByDialog[opponentKey] {
                if !cached.contains(where: { $0.id == messageId }) {
                    cached.append(chatMessage)
                    messagesByDialog[opponentKey] = cached
                }
            } else {
                // First message in this dialog — initialize cache.
                messagesByDialog[opponentKey] = [chatMessage]
            }
            NotificationCenter.default.post(
                name: .sentMessageInserted,
                object: nil,
                userInfo: ["opponentKey": opponentKey]
            )
        } else {
            // Debounced cache refresh — batch during sync
            scheduleCacheRefresh(for: opponentKey)
        }
    }

    func deliveryStatus(forMessageId messageId: String) -> DeliveryStatus? {
        guard !currentAccount.isEmpty else { return nil }
        do {
            return try db.read { db in
                try MessageRecord
                    .filter(
                        MessageRecord.Columns.account == currentAccount &&
                        MessageRecord.Columns.messageId == messageId
                    )
                    .fetchOne(db)
                    .flatMap { DeliveryStatus(rawValue: $0.deliveryStatus) }
            }
        } catch { return nil }
    }

    func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
        PerformanceLogger.shared.track("message.deliveryUpdate")
        guard !currentAccount.isEmpty else { return }

        do {
            let opponentKey: String? = try db.writeSync { db in
                guard let existing = try MessageRecord.filter(
                    MessageRecord.Columns.account == currentAccount &&
                    MessageRecord.Columns.messageId == messageId
                ).fetchOne(db) else { return nil }

                let current = DeliveryStatus(rawValue: existing.deliveryStatus) ?? .waiting
                // Android parity: don't downgrade delivery status.
                if current == .delivered && (status == .waiting || status == .error) { return nil }

                var sql = "UPDATE messages SET delivery_status = ?"
                var args: [any DatabaseValueConvertible] = [status.rawValue]
                if let ts = newTimestamp {
                    sql += ", timestamp = ?"
                    args.append(ts)
                }
                sql += " WHERE account = ? AND message_id = ?"
                args.append(currentAccount)
                args.append(messageId)
                try db.execute(sql: sql, arguments: StatementArguments(args))

                let fromMe = existing.fromMe != 0
                return fromMe ? existing.toPublicKey : existing.fromPublicKey
            }

            if let opponentKey {
                // Performance: patch cache in-memory instead of full reload+decrypt.
                if var cached = messagesByDialog[opponentKey],
                   let idx = cached.firstIndex(where: { $0.id == messageId }) {
                    cached[idx].deliveryStatus = status
                    if let ts = newTimestamp {
                        cached[idx].timestamp = ts
                    }
                    messagesByDialog[opponentKey] = cached
                } else {
                    refreshCacheNow(for: opponentKey)
                }
            }
        } catch {
            print("[DB] updateDeliveryStatus error: \(error)")
        }
    }

    func markIncomingAsRead(opponentKey: String, myPublicKey: String) {
        guard !currentAccount.isEmpty else { return }
        let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
        do {
            try db.writeSync { db in
                try db.execute(
                    sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 0",
                    arguments: [myPublicKey, dialogKey]
                )
            }
        } catch {
            print("[DB] markIncomingAsRead error: \(error)")
        }
        patchCacheMarkIncoming(opponentKey: opponentKey, myPublicKey: myPublicKey)
    }

    /// PERF: Batch mark incoming as read for multiple dialogs in a single DB transaction.
    /// Used by reapplyPendingSyncReads/markActiveDialogsAsRead — reduces N writeSync calls to 1.
    func markIncomingAsReadBatch(opponentKeys: [String], myPublicKey: String) {
        guard !currentAccount.isEmpty, !opponentKeys.isEmpty else { return }
        do {
            try db.writeSync { db in
                for opponentKey in opponentKeys {
                    let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
                    try db.execute(
                        sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 0",
                        arguments: [myPublicKey, dialogKey]
                    )
                }
            }
        } catch {
            print("[DB] markIncomingAsReadBatch error: \(error)")
        }
        for opponentKey in opponentKeys {
            patchCacheMarkIncoming(opponentKey: opponentKey, myPublicKey: myPublicKey)
        }
    }

    private func patchCacheMarkIncoming(opponentKey: String, myPublicKey: String) {
        if var cached = messagesByDialog[opponentKey] {
            var changed = false
            for i in cached.indices where !cached[i].isFromMe(myPublicKey: myPublicKey) && !cached[i].isRead {
                cached[i].isRead = true
                changed = true
            }
            if changed { messagesByDialog[opponentKey] = cached }
        } else {
            refreshCacheNow(for: opponentKey)
        }
    }

    func markOutgoingAsRead(opponentKey: String, myPublicKey: String) {
        guard !currentAccount.isEmpty else { return }
        let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
        do {
            try db.writeSync { db in
                // Android/Desktop parity: set is_read = 1 for outgoing messages (separate column).
                // delivery_status stays unchanged (WAITING/DELIVERED/ERROR).
                // Android: `UPDATE messages SET read = 1 WHERE from_me = 1 AND read != 1`
                try db.execute(
                    sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 1 AND is_read = 0",
                    arguments: [myPublicKey, dialogKey]
                )
            }
        } catch {
            print("[DB] markOutgoingAsRead error: \(error)")
        }
        patchCacheMarkOutgoing(opponentKey: opponentKey, myPublicKey: myPublicKey)
    }

    /// PERF: Batch mark outgoing as read for multiple dialogs in a single DB transaction.
    /// Used by reapplyPendingOpponentReads — reduces N writeSync calls to 1.
    func markOutgoingAsReadBatch(opponentKeys: [String], myPublicKey: String) {
        guard !currentAccount.isEmpty, !opponentKeys.isEmpty else { return }
        do {
            try db.writeSync { db in
                for opponentKey in opponentKeys {
                    let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
                    try db.execute(
                        sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 1 AND is_read = 0",
                        arguments: [myPublicKey, dialogKey]
                    )
                }
            }
        } catch {
            print("[DB] markOutgoingAsReadBatch error: \(error)")
        }
        for opponentKey in opponentKeys {
            patchCacheMarkOutgoing(opponentKey: opponentKey, myPublicKey: myPublicKey)
        }
    }

    private func patchCacheMarkOutgoing(opponentKey: String, myPublicKey: String) {
        if var cached = messagesByDialog[opponentKey] {
            var changed = false
            for i in cached.indices where cached[i].isFromMe(myPublicKey: myPublicKey) && !cached[i].isRead {
                cached[i].isRead = true
                changed = true
            }
            if changed { messagesByDialog[opponentKey] = cached }
        } else {
            refreshCacheNow(for: opponentKey)
        }
    }

    /// Updates attachment password for a specific message (used during retry with re-encryption).
    func updateAttachmentPassword(messageId: String, password: String) {
        guard !currentAccount.isEmpty else { return }
        do {
            try db.writeSync { db in
                try db.execute(
                    sql: "UPDATE messages SET attachment_password = ? WHERE account = ? AND message_id = ?",
                    arguments: [password, currentAccount, messageId]
                )
            }
        } catch {
            print("[DB] updateAttachmentPassword error: \(error)")
        }
    }

    // MARK: - Typing

    func markTyping(from dialogKey: String, senderKey: String) {
        var current = typingDialogs[dialogKey] ?? []
        if !current.contains(senderKey) {
            current.insert(senderKey)
            typingDialogs[dialogKey] = current
        }
        // Per-sender timeout (Desktop parity: each typer has own 3s timer)
        let timerKey = "\(dialogKey)::\(senderKey)"
        typingResetTasks[timerKey]?.cancel()
        typingResetTasks[timerKey] = Task { @MainActor [weak self] in
            try? await Task.sleep(for: .seconds(3))
            guard let self, !Task.isCancelled else { return }
            self.typingDialogs[dialogKey]?.remove(senderKey)
            if self.typingDialogs[dialogKey]?.isEmpty == true {
                self.typingDialogs.removeValue(forKey: dialogKey)
            }
            self.typingResetTasks.removeValue(forKey: timerKey)
        }
    }

    func isTyping(dialogKey: String) -> Bool {
        guard let set = typingDialogs[dialogKey] else { return false }
        return !set.isEmpty
    }

    // MARK: - Delete

    func deleteDialog(_ dialogKey: String) {
        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        do {
            try db.writeSync { db in
                try db.execute(
                    sql: "DELETE FROM messages WHERE account = ? AND dialog_key = ?",
                    arguments: [currentAccount, dbDialogKey]
                )
            }
        } catch {
            print("[DB] deleteDialog error: \(error)")
        }
        messagesByDialog.removeValue(forKey: dialogKey)
        activeDialogs.remove(dialogKey)
        readEligibleDialogs.remove(dialogKey)
        typingDialogs.removeValue(forKey: dialogKey)
        cancelTypingTimers(for: dialogKey)
    }

    /// Cancel all per-sender typing timers for a dialog.
    private func cancelTypingTimers(for dialogKey: String) {
        let prefix = "\(dialogKey)::"
        for (key, task) in typingResetTasks where key.hasPrefix(prefix) {
            task.cancel()
            typingResetTasks.removeValue(forKey: key)
        }
    }

    func deleteMessage(id: String) {
        guard !currentAccount.isEmpty else { return }
        var affectedOpponent: String?
        do {
            affectedOpponent = try db.writeSync { db in
                let record = try MessageRecord.filter(
                    MessageRecord.Columns.account == currentAccount &&
                    MessageRecord.Columns.messageId == id
                ).fetchOne(db)
                guard let record else { return nil as String? }
                try db.execute(
                    sql: "DELETE FROM messages WHERE account = ? AND message_id = ?",
                    arguments: [currentAccount, id]
                )
                let fromMe = record.fromMe != 0
                return fromMe ? record.toPublicKey : record.fromPublicKey
            }
        } catch {
            print("[DB] deleteMessage error: \(error)")
        }
        if let opponentKey = affectedOpponent {
            // In-memory remove — no full re-decrypt needed.
            if var cached = messagesByDialog[opponentKey] {
                cached.removeAll { $0.id == id }
                messagesByDialog[opponentKey] = cached
            }
        }
    }

    /// Android parity: `markExpiredWaitingAsError()` — batch SQL UPDATE for old WAITING messages.
    /// Returns the number of rows updated.
    @discardableResult
    func markExpiredWaitingAsError(myPublicKey: String, maxTimestamp: Int64) -> Int {
        do {
            return try db.writeSync { db in
                try db.execute(
                    sql: """
                        UPDATE messages SET delivery_status = ?
                        WHERE account = ? AND from_me = 1 AND delivery_status = ? AND timestamp < ?
                        """,
                    arguments: [
                        DeliveryStatus.error.rawValue,
                        myPublicKey,
                        DeliveryStatus.waiting.rawValue,
                        maxTimestamp,
                    ]
                )
                return db.changesCount
            }
        } catch {
            print("[DB] markExpiredWaitingAsError error: \(error)")
            return 0
        }
    }

    func resolveRetryableOutgoingMessages(
        myPublicKey: String,
        nowMs: Int64,
        maxRetryAgeMs: Int64
    ) -> [ChatMessage] {
        let cutoff = nowMs - maxRetryAgeMs
        do {
            let records = try db.writeSync { db -> [MessageRecord] in
                let results = try MessageRecord.filter(
                    MessageRecord.Columns.account == myPublicKey &&
                    MessageRecord.Columns.fromMe == 1 &&
                    (MessageRecord.Columns.deliveryStatus == DeliveryStatus.waiting.rawValue ||
                     MessageRecord.Columns.deliveryStatus == DeliveryStatus.error.rawValue) &&
                    MessageRecord.Columns.timestamp >= cutoff
                ).fetchAll(db)

                try db.execute(
                    sql: """
                        UPDATE messages SET delivery_status = ?
                        WHERE account = ? AND from_me = 1 AND delivery_status = ? AND timestamp >= ?
                        """,
                    arguments: [DeliveryStatus.waiting.rawValue, myPublicKey, DeliveryStatus.error.rawValue, cutoff]
                )

                return results
            }
            return records.map { $0.toChatMessage() }
        } catch {
            print("[DB] resolveRetryable error: \(error)")
            return []
        }
    }

    // MARK: - Attachment Update

    func updateAttachments(messageId: String, attachments: [MessageAttachment]) {
        guard !currentAccount.isEmpty else { return }
        let encoder = JSONEncoder()
        guard let data = try? encoder.encode(attachments),
              let json = String(data: data, encoding: .utf8) else { return }

        var affectedOpponent: String?
        do {
            affectedOpponent = try db.writeSync { db in
                let record = try MessageRecord.filter(
                    MessageRecord.Columns.account == currentAccount &&
                    MessageRecord.Columns.messageId == messageId
                ).fetchOne(db)
                try db.execute(
                    sql: "UPDATE messages SET attachments = ? WHERE account = ? AND message_id = ?",
                    arguments: [json, currentAccount, messageId]
                )
                guard let record else { return nil as String? }
                let fromMe = record.fromMe != 0
                return fromMe ? record.toPublicKey : record.fromPublicKey
            }
        } catch {
            print("[DB] updateAttachments error: \(error)")
        }
        if let opponentKey = affectedOpponent {
            // In-memory patch — no full re-decrypt needed.
            if var cached = messagesByDialog[opponentKey],
               let idx = cached.firstIndex(where: { $0.id == messageId }) {
                cached[idx].attachments = attachments
                messagesByDialog[opponentKey] = cached
            }
        }
    }

    // MARK: - Unread Count (Android parity: SELECT COUNT(*) WHERE read = 0)

    func countUnread(account: String, opponentKey: String) -> Int {
        let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey)
        do {
            let count = try db.read { db in
                try Int.fetchOne(db,
                    sql: "SELECT COUNT(*) FROM messages WHERE account = ? AND dialog_key = ? AND from_me = 0 AND is_read = 0",
                    arguments: [account, dialogKey]
                ) ?? 0
            }
            return count
        } catch { return 0 }
    }

    /// Android parity: `hasSentByDialogKey()` — checks ALL messages in DB (not just last N).
    /// Returns true if any outgoing message exists for this dialog.
    func hasSentMessages(account: String, opponentKey: String) -> Bool {
        let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey)
        do {
            return try db.read { db in
                try Bool.fetchOne(db,
                    sql: """
                        SELECT EXISTS(
                            SELECT 1 FROM messages
                            WHERE account = ? AND dialog_key = ? AND from_me = 1
                            LIMIT 1
                        )
                        """,
                    arguments: [account, dialogKey]
                ) ?? false
            }
        } catch { return false }
    }

    /// Returns the last message for a dialog, DECRYPTED.
    /// Used by DialogRepository.updateDialogFromMessages() to set lastMessage text.
    func lastDecryptedMessage(account: String, opponentKey: String) -> ChatMessage? {
        let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey)
        // Fetch raw record inside db.read, then decrypt OUTSIDE the transaction.
        // decryptRecord → tryReDecrypt → GroupRepository.groupKey() opens its own db.read,
        // which would crash with "Database methods are not reentrant" if still inside a transaction.
        let record: MessageRecord?
        do {
            record = try db.read { db in
                try MessageRecord
                    .filter(
                        MessageRecord.Columns.account == account &&
                        MessageRecord.Columns.dialogKey == dialogKey
                    )
                    .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
                    .fetchOne(db)
            }
        } catch { return nil }
        guard let record else { return nil }
        return decryptRecord(record)
    }

    // MARK: - Local Service Messages (Desktop parity: $a= prefix)

    /// Inserts a local-only service message (e.g. "$a=Group created", "$a=Group joined").
    /// Desktop: useGroups.ts:206-216 — INSERT into messages with `$a=Group joined`.
    func insertLocalServiceMessage(text: String, dialogKey: String) {
        guard !currentAccount.isEmpty else { return }
        let messageId = "svc_\(UUID().uuidString)"
        let timestamp = Int64(Date().timeIntervalSince1970 * 1000)

        var record = MessageRecord(
            id: nil,
            account: currentAccount,
            fromPublicKey: currentAccount,
            toPublicKey: dialogKey,
            content: "",
            chachaKey: "",
            text: text,
            plainMessage: text,
            timestamp: timestamp,
            isRead: 1,
            readAlias: 1,
            fromMe: 1,
            deliveryStatus: DeliveryStatus.delivered.rawValue,
            deliveredAlias: DeliveryStatus.delivered.rawValue,
            messageId: messageId,
            replyToMessageId: nil,
            dialogKey: DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey),
            attachments: "[]",
            attachmentPassword: nil
        )

        do {
            try db.writeSync { db in
                try record.insert(db, onConflict: .ignore)
            }
        } catch {
            print("[DB] insertLocalServiceMessage error: \(error)")
        }
        refreshCacheNow(for: dialogKey)
    }

    // MARK: - Stress Test (Debug only)

    func insertStressTestMessage(_ message: ChatMessage, dialogKey: String) {
        guard !currentAccount.isEmpty else { return }
        do {
            try db.writeSync { db in
                var record = MessageRecord.from(message, account: currentAccount)
                try record.insert(db, onConflict: .ignore)
            }
        } catch {
            print("[DB] insertStressTest error: \(error)")
        }
        refreshCacheNow(for: dialogKey)
    }

    // MARK: - Reset

    func reset(clearPersisted: Bool = false) {
        for task in typingResetTasks.values { task.cancel() }
        typingResetTasks.removeAll()
        messagesByDialog.removeAll()
        typingDialogs.removeAll()
        activeDialogs.removeAll()
        readEligibleDialogs.removeAll()
        processedMessageIds.removeAll()
        pendingCacheRefresh.removeAll()
        cacheRefreshTask?.cancel()
        privateKey = ""

        guard !currentAccount.isEmpty else { return }
        let account = currentAccount
        currentAccount = ""

        guard clearPersisted else { return }
        do {
            try db.writeSync { db in
                try db.execute(sql: "DELETE FROM messages WHERE account = ?", arguments: [account])
            }
        } catch {
            print("[DB] reset error: \(error)")
        }
    }

    /// No-op — SQLite writes are immediate.
    func persistNow() {}
    /// No-op — batch updates handled by debounced cache refresh.
    func beginBatchUpdates() {}
    /// Flush any pending cache refreshes immediately.
    func endBatchUpdates() {
        flushPendingCacheRefreshes()
    }

    // MARK: - Private: Cache Management

    /// Android parity: debounce cache refresh at 100ms during sync.
    /// Prevents N separate @Published updates for N messages in a sync batch.
    private func scheduleCacheRefresh(for opponentKey: String) {
        pendingCacheRefresh.insert(opponentKey)
        cacheRefreshTask?.cancel()
        cacheRefreshTask = Task { @MainActor [weak self] in
            try? await Task.sleep(for: .milliseconds(100))
            guard let self, !Task.isCancelled else { return }
            self.flushPendingCacheRefreshes()
        }
    }

    /// Force-refresh cache for a specific dialog (bypasses debounce + activeDialogs guard).
    /// Used when a message is inserted programmatically (e.g., call attachment) and must
    /// appear immediately regardless of dialog active state.
    func refreshDialogCache(for opponentKey: String) {
        let messages = loadMessagesFromDB(dialogKey: opponentKey, limit: Self.maxCacheSize)
        messagesByDialog[opponentKey] = messages
    }

    /// Immediately refresh all pending dialog caches.
    private func flushPendingCacheRefreshes() {
        let pending = pendingCacheRefresh
        pendingCacheRefresh.removeAll()
        cacheRefreshTask?.cancel()
        cacheRefreshTask = nil
        for opponentKey in pending {
            refreshCacheNow(for: opponentKey)
        }
    }

    /// Refresh cache for a dialog immediately (no debounce). Used for user-initiated actions.
    /// Android parity: capped at MAX_CACHE_SIZE (500) to prevent OOM.
    private func refreshCacheNow(for opponentKey: String) {
        guard messagesByDialog[opponentKey] != nil || activeDialogs.contains(opponentKey) else { return }
        let messages = loadMessagesFromDB(dialogKey: opponentKey, limit: Self.maxCacheSize)
        messagesByDialog[opponentKey] = messages
    }

    private func loadMessagesFromDB(dialogKey: String, limit: Int?) -> [ChatMessage] {
        let dbDialogKey = DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey)
        do {
            let records = try db.read { db in
                if let limit {
                    let descRequest = MessageRecord
                        .filter(
                            MessageRecord.Columns.account == currentAccount &&
                            MessageRecord.Columns.dialogKey == dbDialogKey
                        )
                        .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
                        .limit(limit)
                    let records = try descRequest.fetchAll(db)
                    return Array(records.reversed())
                }
                return try MessageRecord
                    .filter(
                        MessageRecord.Columns.account == currentAccount &&
                        MessageRecord.Columns.dialogKey == dbDialogKey
                    )
                    .order(MessageRecord.Columns.timestamp.asc, MessageRecord.Columns.messageId.asc)
                    .fetchAll(db)
            }
            return records.map { decryptRecord($0) }
        } catch {
            print("[DB] loadMessages error: \(error)")
            return []
        }
    }

    /// Android parity: decrypt `plain_message` (encrypted with private key) on read.
    /// Uses `safePlainMessageFallback()` — NEVER shows raw ciphertext to user.
    /// Android: `entityToChatMessage()` in `ChatViewModel.kt` lines 1082-1159.
    private func decryptRecord(_ record: MessageRecord) -> ChatMessage {
        let plainText: String

        if !privateKey.isEmpty {
            // Prefer requireCompression: true — encryptWithPassword always uses rawDeflate,
            // so decompression acts as verification. The uncompressed fallback (~1/256
            // false-positive chance with wrong key) can return AES garbage.
            if let data = try? CryptoManager.shared.decryptWithPassword(
                record.text, password: privateKey, requireCompression: true
            ),
               let decrypted = String(data: data, encoding: .utf8) {
                plainText = decrypted
            } else if let data = try? CryptoManager.shared.decryptWithPassword(
                record.text, password: privateKey
            ),
                      let decrypted = String(data: data, encoding: .utf8),
                      !Self.isProbablyEncryptedPayload(decrypted) {
                // Uncompressed fallback for legacy messages — but reject if result
                // looks like ciphertext (false-positive AES with wrong key).
                plainText = decrypted
            } else {
                let fallback = Self.safePlainMessageFallback(record.text)

                // Desktop/Android parity: lazy re-decrypt from encrypted wire content.
                // When a message was stored with decrypt failure (empty text but content
                // and chachaKey present), try ECDH/group decryption with current keys.
                if fallback.isEmpty, !record.content.isEmpty {
                    if let retried = Self.tryReDecrypt(record: record, privateKeyHex: privateKey) {
                        plainText = retried
                        // Persist successful re-decrypt to DB so we don't retry every load
                        persistReDecryptedText(retried, forMessageId: record.messageId, privateKey: privateKey)
                    } else {
                        plainText = fallback
                    }
                } else {
                    plainText = fallback
                }
            }
        } else {
            plainText = Self.safePlainMessageFallback(record.text)
        }

        return record.toChatMessage(overrideText: plainText)
    }

    // MARK: - Lazy Re-Decryption (Desktop/Android Parity)

    /// Attempts to decrypt a message from its stored encrypted wire content.
    /// Tries ECDH path (direct messages) and group key path (group messages).
    /// Called when primary decryption (from `text` column) fails but raw content exists.
    private static func tryReDecrypt(record: MessageRecord, privateKeyHex: String) -> String? {
        guard !record.content.isEmpty else { return nil }

        // Path 1: ECDH path — chachaKey + privateKey → XChaCha20 decrypt
        if !record.chachaKey.isEmpty {
            do {
                let (text, _) = try MessageCrypto.decryptIncomingFull(
                    ciphertext: record.content,
                    encryptedKey: record.chachaKey,
                    myPrivateKeyHex: privateKeyHex
                )
                return text
            } catch {
                // Fall through to group path
            }
        }

        // Path 2: Group messages — try group key if dialog is a group.
        // Group dialogKeys are stored as-is (e.g., "#group:xxx"), not "account:opponent".
        if DatabaseManager.isGroupDialogKey(record.dialogKey) {
            if let groupKey = GroupRepository.shared.groupKey(
                account: record.account,
                privateKeyHex: privateKeyHex,
                groupDialogKey: record.dialogKey
            ) {
                if let data = try? CryptoManager.shared.decryptWithPassword(
                    record.content, password: groupKey
                ), let text = String(data: data, encoding: .utf8) {
                    return text
                }
            }
        }

        return nil
    }

    /// Persists successfully re-decrypted text back to DB.
    private func persistReDecryptedText(_ text: String, forMessageId messageId: String, privateKey: String) {
        guard !currentAccount.isEmpty else { return }
        let storedText: String
        if let enc = try? CryptoManager.shared.encryptWithPassword(Data(text.utf8), password: privateKey) {
            storedText = enc
        } else {
            storedText = text
        }
        do {
            try db.writeSync { db in
                try db.execute(
                    sql: "UPDATE messages SET text = ?, plain_message = ? WHERE account = ? AND message_id = ?",
                    arguments: [storedText, storedText, currentAccount, messageId]
                )
            }
        } catch {
            // Non-critical — message will retry on next load
        }
    }

    // MARK: - Batch Re-Decrypt (Group Key Recovery)

    /// Re-decrypts all messages in a group dialog that have empty text but non-empty content.
    /// Called when a group key becomes available AFTER messages were already stored without it.
    func reDecryptGroupMessages(
        account: String,
        groupDialogKey: String,
        groupKey: String,
        privateKeyHex: String
    ) {
        guard !groupKey.isEmpty, !privateKeyHex.isEmpty else { return }
        let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: groupDialogKey)

        // Batch processing with LIMIT to prevent OOM on large groups.
        let batchSize = 100
        var offset = 0
        var totalUpdated = 0

        while true {
            let records: [MessageRecord]
            do {
                records = try db.read { db in
                    try MessageRecord
                        .filter(
                            MessageRecord.Columns.account == account &&
                            MessageRecord.Columns.dialogKey == dialogKey &&
                            MessageRecord.Columns.content != ""
                        )
                        .order(MessageRecord.Columns.timestamp.asc)
                        .limit(batchSize, offset: offset)
                        .fetchAll(db)
                }
            } catch { break }

            guard !records.isEmpty else { break }

            for record in records {
                guard let data = try? CryptoManager.shared.decryptWithPassword(
                    record.content, password: groupKey
                ), let text = String(data: data, encoding: .utf8), !text.isEmpty else {
                    continue
                }
                persistReDecryptedText(text, forMessageId: record.messageId, privateKey: privateKeyHex)
                totalUpdated += 1
            }

            offset += batchSize
            if records.count < batchSize { break }
        }

        if totalUpdated > 0 {
            refreshDialogCache(for: groupDialogKey)
        }
    }

    // MARK: - Android Parity: safePlainMessageFallback

    /// Android parity: `safePlainMessageFallback()` in `ChatViewModel.kt` lines 1338-1349.
    /// NEVER show raw ciphertext in UI. If text looks like encrypted payload, return "".
    private static func safePlainMessageFallback(_ raw: String) -> String {
        if raw.isEmpty { return "" }
        if isProbablyEncryptedPayload(raw) { return "" }
        return raw  // Looks like real plaintext (legacy unencrypted record)
    }

    /// Android parity: `isProbablyEncryptedPayload()` — detects encrypted formats.
    private static func isProbablyEncryptedPayload(_ value: String) -> Bool {
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        // Chunked format
        if trimmed.hasPrefix("CHNK:") { return true }
        // ivBase64:ctBase64 format — both parts must be ≥16 chars to avoid
        // false positives on short strings like "hello:world".
        // Aligned with MessageCellLayout.isGarbageOrEncrypted() and
        // DialogRepository.isGarbageText().
        let parts = trimmed.components(separatedBy: ":")
        if parts.count == 2 {
            let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
            if parts.allSatisfy({ part in
                part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
            }) { return true }
        }
        // Pure hex string (≥40 chars) — XChaCha20 wire format
        if trimmed.count >= 40 {
            let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
            if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
        }
        return false
    }

#if DEBUG
    internal static func testIsProbablyEncrypted(_ value: String) -> Bool {
        isProbablyEncryptedPayload(value)
    }
#endif

    private func normalizeTimestamp(_ raw: Int64) -> Int64 {
        raw < 1_000_000_000_000 ? raw * 1000 : raw
    }
}

// MARK: - LRU Set (Android parity: LinkedHashSet with max size)

/// Thread-safe LRU set with bounded size. Evicts oldest entry on overflow.
/// Android parity: `processedMessageIds` in `MessageRepository.kt`.
struct LRUSet<Element: Hashable> {
    private var order: [Element] = []
    private var set: Set<Element> = []
    private let maxSize: Int

    init(maxSize: Int) {
        self.maxSize = maxSize
    }

    mutating func contains(_ element: Element) -> Bool {
        set.contains(element)
    }

    mutating func insert(_ element: Element) {
        guard !set.contains(element) else { return }
        if order.count >= maxSize {
            let evicted = order.removeFirst()
            set.remove(evicted)
        }
        order.append(element)
        set.insert(element)
    }

    mutating func removeAll() {
        order.removeAll()
        set.removeAll()
    }
}
