import Foundation
import Observation
import os
import UIKit
import CommonCrypto
import UserNotifications

/// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle.
@Observable
@MainActor
final class SessionManager {

    static let shared = SessionManager()

    nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session")

    enum StartSessionError: LocalizedError {
        case invalidCredentials
        case databaseBootstrapFailed(underlying: Error)

        var errorDescription: String? {
            switch self {
            case .invalidCredentials:
                return "Wrong password. Please try again."
            case .databaseBootstrapFailed:
                return "Database migration failed. Please restart the app and try again."
            }
        }
    }

    private(set) var isAuthenticated = false
    private(set) var currentPublicKey: String = ""
    private(set) var displayName: String = ""
    private(set) var username: String = ""
    private(set) var verified: Int = 0

    /// Hex-encoded private key hash, kept in memory for the session duration.
    private(set) var privateKeyHash: String?
    /// Hex-encoded raw private key, kept in memory for message decryption.
    private(set) var privateKeyHex: String?
    private var lastTypingSentAt: [String: Int64] = [:]
    /// Desktop parity: exposed so chat list can suppress unread badges during sync.
    private(set) var syncBatchInProgress = false
    private var syncRequestInFlight = false
    /// One-time flag: prevents infinite recovery sync loop.
    private var hasTriggeredGroupRecoverySync = false
    private var pendingIncomingMessages: [PacketMessage] = []
    private var isProcessingIncomingMessages = false
    /// Drop+resync recovery for malformed 0x06 packets.
    private var malformedMessageResyncTask: Task<Void, Never>?
    private var malformedMessageResyncQueued = false
    private static let malformedMessageResyncDebounceNs: UInt64 = 350_000_000
    private static let malformedMessageResyncRetryDelayNs: UInt64 = 200_000_000
    /// Android parity: tracks the latest incoming message timestamp per dialog
    /// for which a read receipt was already sent. Prevents redundant sends.
    private var lastReadReceiptTimestamp: [String: Int64] = [:]
    /// Dialogs recently read on another device — suppresses in-app banner for 30s.
    private var desktopActiveDialogs: [String: Date] = [:]
    /// Cross-device reads received during sync — re-applied at BATCH_END.
    /// PacketRead can arrive BEFORE the sync messages, so markIncomingAsRead
    /// updates 0 rows. Re-applying after sync ensures the read state sticks.
    private var pendingSyncReads: Set<String> = []
    /// Opponent read receipts received during sync — re-applied at BATCH_END.
    /// Mirrors `pendingSyncReads` for own-device reads. Without this,
    /// markOutgoingAsRead() races with message insertion (0 rows affected)
    /// because iOS processes reads via separate Task, not the inbound queue.
    /// Android/Desktop don't need this — all packets go through one FIFO queue.
    private var pendingOpponentReads: Set<String> = []
    private var requestedUserInfoKeys: Set<String> = []
    private var onlineSubscribedKeys: Set<String> = []
    private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
    private var pendingOutgoingPackets: [String: PacketMessage] = [:]
    private var pendingOutgoingAttempts: [String: Int] = [:]
    /// Guards against rapid duplicate push subscribe sends during reconnect storms.
    private var lastPushTokenSubscribe: (token: String, sentAt: TimeInterval)?
    /// Guards against rapid duplicate VoIP subscribe sends during reconnect storms.
    private var lastVoIPTokenSubscribe: (token: String, sentAt: TimeInterval)?
    private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
    private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
    var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
    var packetFlowSender: PacketFlowSending = LivePacketFlowSender()
    /// Message IDs whose outgoing send was cancelled by the user.
    private var cancelledOutgoingIds: Set<String> = []
    /// Guards onHandshakeCompleted from running before repositories are ready.
    /// Set after DialogRepository + MessageRepository bootstraps complete in startSession().
    private(set) var repositoriesReady = false

    // MARK: - Foreground & Idle Detection (Desktop/Android parity)

    private var foregroundObserverToken: NSObjectProtocol?
    private var backgroundObserverToken: NSObjectProtocol?
    /// Android parity: 5s debounce between foreground sync requests.
    private var lastForegroundSyncTime: TimeInterval = 0

    /// Desktop/Android parity: 20s idle timeout clears read eligibility.
    /// Desktop: `useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000)` (20s).
    /// Android: `useIdle(20000)`.
    /// Reset by ChatDetailView on scroll/tap via `resetIdleTimer()`.
    private static let idleTimeoutSeconds: TimeInterval = 20
    private var idleTimer: Timer?
    private(set) var isUserIdle = false

    /// Whether the app is in the foreground.
    private var isAppInForeground: Bool {
        UIApplication.shared.applicationState == .active
    }

    private var userInfoSearchHandlerToken: UUID?
    #if DEBUG
    private var malformedMessageResyncTestHook: (() -> Void)?
    private(set) var malformedMessageResyncTriggerCount: Int = 0
    #endif

    private init() {
        setupProtocolCallbacks()
        setupUserInfoSearchHandler()
        setupForegroundObserver()
    }

    /// Re-apply cross-device reads that were received during sync.
    /// Fixes race: PacketRead arrives before sync messages → markIncomingAsRead
    /// updates 0 rows → messages stay unread. After sync delivers the messages,
    /// this re-marks them as read.
    private func reapplyPendingSyncReads() {
        guard !pendingSyncReads.isEmpty else { return }
        let keys = pendingSyncReads
        pendingSyncReads.removeAll()
        let myKey = currentPublicKey
        // PERF: batch DB writes into single transaction instead of N separate writeSync calls
        MessageRepository.shared.markIncomingAsReadBatch(
            opponentKeys: Array(keys), myPublicKey: myKey
        )
        for opponentKey in keys {
            DialogRepository.shared.markAsRead(opponentKey: opponentKey)
        }
    }

    /// Re-apply opponent read receipts that raced with message insertion during sync.
    /// Android parity: Android doesn't need this because all packets go through one FIFO
    /// queue (launchInboundPacketTask). iOS processes reads via separate Task, so they
    /// can execute before messages are committed to DB → markOutgoingAsRead updates 0 rows.
    private func reapplyPendingOpponentReads() {
        guard !pendingOpponentReads.isEmpty else { return }
        let keys = pendingOpponentReads
        pendingOpponentReads.removeAll()
        let myKey = currentPublicKey
        // PERF: batch DB writes into single transaction instead of N separate writeSync calls
        MessageRepository.shared.markOutgoingAsReadBatch(
            opponentKeys: Array(keys), myPublicKey: myKey
        )
        for opponentKey in keys {
            DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey)
        }
    }

    /// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts.
    /// Called on foreground resume. Safe after background clear: readEligibleDialogs is empty.
    func markActiveDialogsAsRead() {
        let activeKeys = MessageRepository.shared.activeDialogKeys
        let myKey = currentPublicKey
        // Collect eligible keys first, then batch DB writes
        var eligibleKeys: [String] = []
        for dialogKey in activeKeys {
            guard !SystemAccounts.isSystemAccount(dialogKey) else { continue }
            guard MessageRepository.shared.isDialogReadEligible(dialogKey) else { continue }
            eligibleKeys.append(dialogKey)
        }
        guard !eligibleKeys.isEmpty else { return }
        // PERF: batch DB writes into single transaction
        MessageRepository.shared.markIncomingAsReadBatch(
            opponentKeys: eligibleKeys, myPublicKey: myKey
        )
        for dialogKey in eligibleKeys {
            DialogRepository.shared.markAsRead(opponentKey: dialogKey)
            sendReadReceipt(toPublicKey: dialogKey)
        }
    }

    // MARK: - Idle Detection (Desktop/Android parity: 20s)

    /// Reset the idle timer. Called by ChatDetailView on user interaction
    /// (scroll, tap, message send). Restores read eligibility if was idle.
    func resetIdleTimer() {
        idleTimer?.invalidate()
        if isUserIdle {
            isUserIdle = false
            // ChatDetailView will call updateReadEligibility() on its own
        }
        idleTimer = Timer.scheduledTimer(withTimeInterval: Self.idleTimeoutSeconds, repeats: false) { [weak self] _ in
            Task { @MainActor [weak self] in
                guard let self, !self.isUserIdle else { return }
                self.isUserIdle = true
                MessageRepository.shared.clearAllReadEligibility()
            }
        }
    }

    /// Stop the idle timer (e.g., on session end or leaving chat).
    func stopIdleTimer() {
        idleTimer?.invalidate()
        idleTimer = nil
        isUserIdle = false
    }

    // MARK: - Session Lifecycle

    /// Called after password verification. Decrypts private key, connects WebSocket, and starts handshake.
    func startSession(password: String) async throws {
        let accountManager = AccountManager.shared
        let crypto = CryptoManager.shared
        repositoriesReady = false

        #if DEBUG
        let sessionStart = CFAbsoluteTimeGetCurrent()
        #endif

        // 1. Decrypt private key
        let privateKeyHex: String
        do {
            privateKeyHex = try await accountManager.decryptPrivateKey(password: password)
        } catch {
            throw StartSessionError.invalidCredentials
        }
        self.privateKeyHex = privateKeyHex
        // Android parity: provide private key to caches for encryption at rest
        AttachmentCache.shared.privateKey = privateKeyHex

        #if DEBUG
        Self.logger.info("⏱ CONN_PERF: decryptPrivateKey \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
        #endif

        guard let account = accountManager.currentAccount else {
            throw CryptoError.decryptionFailed
        }

        // 2. Open SQLite database for this account (must happen before repository bootstrap).
        do {
            try DatabaseManager.shared.bootstrap(accountPublicKey: account.publicKey)
        } catch {
            self.privateKeyHex = nil
            AttachmentCache.shared.privateKey = nil
            Self.logger.error("Database bootstrap failed: \(error.localizedDescription)")
            throw StartSessionError.databaseBootstrapFailed(underlying: error)
        }

        #if DEBUG
        Self.logger.info("⏱ CONN_PERF: databaseBootstrap \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
        #endif

        // 3. Generate hash + start TCP+TLS EARLY — overlaps with repository bootstraps.
        // connect() is non-blocking: creates URLSessionWebSocketTask and returns immediately.
        // TCP+TLS handshake (200-500ms) runs in parallel with steps 4-6 below.
        let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
        privateKeyHash = hash
        ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash)
        // Persist minimal credentials for background VoIP push wake-up.
        // PushKit can restore WebSocket connection without user interaction.
        SessionCredentialsManager.shared.save(publicKey: account.publicKey, privateKeyHash: hash)

        #if DEBUG
        Self.logger.info("⏱ CONN_PERF: connectCalled \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
        #endif

        // 4. Setup account state (fast, in-memory only)
        currentPublicKey = account.publicKey
        displayName = account.displayName ?? ""
        username = account.username ?? ""
        CallManager.shared.bindAccount(publicKey: account.publicKey)

        // 5. Migrate legacy JSON → SQLite on first launch (before repositories read from DB).
        let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded(
            accountPublicKey: account.publicKey,
            storagePassword: privateKeyHex
        )
        if migrated > 0 {
            Self.logger.info("Migrated \(migrated) messages from JSON to SQLite")
        }

        // 6. Parallel repository bootstraps — TCP+TLS runs concurrently in background.
        // GRDB DatabasePool supports concurrent reads via WAL mode. No shared state.
        async let dialogBoot: Void = DialogRepository.shared.bootstrap(
            accountPublicKey: account.publicKey,
            storagePassword: privateKeyHex
        )
        async let messageBoot: Void = MessageRepository.shared.bootstrap(
            accountPublicKey: account.publicKey,
            storagePassword: privateKeyHex
        )
        await dialogBoot
        await messageBoot
        repositoriesReady = true

        #if DEBUG
        Self.logger.info("⏱ CONN_PERF: repositoriesReady \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
        #endif

        RecentSearchesRepository.shared.setAccount(account.publicKey)

        // Desktop parity: send release notes as a system message from "Rosetta Updates"
        // account if the app version changed since the last notice.
        sendReleaseNotesIfNeeded(publicKey: account.publicKey)

        // Pre-warm PBKDF2 cache for message storage encryption.
        // First encryptWithPassword() call costs 50-100ms (PBKDF2 derivation).
        // All subsequent calls use NSLock-protected cache (<1ms).
        // Fire-and-forget on background thread — completes before first sync message arrives.
        let pkForCache = privateKeyHex
        Task.detached(priority: .utility) {
            _ = CryptoManager.shared.cachedPBKDF2(password: pkForCache)
        }

        isAuthenticated = true

        #if DEBUG
        Self.logger.info("⏱ CONN_PERF: sessionReady \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
        #endif
    }

    // MARK: - Message Sending

    /// Sends an encrypted message to a recipient, matching Android's outgoing flow.
    func sendMessage(text: String, toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws {
        PerformanceLogger.shared.track("session.sendMessage")
        // Desktop parity: validate message is not empty/whitespace-only before sending.
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            Self.logger.debug("📤 Ignoring empty message")
            return
        }

        guard let privKey = privateKeyHex, let hash = privateKeyHash else {
            Self.logger.error("📤 Cannot send — missing keys")
            throw CryptoError.decryptionFailed
        }

        let connState = ProtocolManager.shared.connectionState
        Self.logger.info("📤 sendMessage to \(toPublicKey.prefix(12))… conn=\(String(describing: connState))")

        let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
        let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
        let packet = try makeOutgoingPacket(
            text: text,
            toPublicKey: toPublicKey,
            messageId: messageId,
            timestamp: timestamp,
            privateKeyHex: privKey,
            privateKeyHash: hash
        )
        let targetDialogKey = packet.toPublicKey

        // Prefer caller-provided title/username (from ChatDetailView route),
        // fall back to existing dialog data, then empty.
        let existingDialog = DialogRepository.shared.dialogs[targetDialogKey]
        let groupMetadata = GroupRepository.shared.groupMetadata(
            account: currentPublicKey,
            groupDialogKey: targetDialogKey
        )
        let title = !opponentTitle.isEmpty
            ? opponentTitle
            : (existingDialog?.opponentTitle.isEmpty == false
                ? (existingDialog?.opponentTitle ?? "")
                : (groupMetadata?.title ?? ""))
        let username = !opponentUsername.isEmpty
            ? opponentUsername
            : (existingDialog?.opponentUsername.isEmpty == false
                ? (existingDialog?.opponentUsername ?? "")
                : (groupMetadata?.description ?? ""))
        DialogRepository.shared.ensureDialog(
            opponentKey: targetDialogKey,
            title: title,
            username: username,
            myPublicKey: currentPublicKey
        )

        // Android parity: always insert as WAITING (status=0) regardless of connection state.
        // Packet is queued in ProtocolManager and sent on reconnect; retry mechanism
        // handles timeout (80s → ERROR). Previously iOS marked offline sends as ERROR
        // immediately (Desktop parity), but this caused false ❌ on brief WebSocket drops.
        // Android keeps WAITING (⏳) and delivers automatically — much better UX.
        MessageRepository.shared.upsertFromMessagePacket(
            packet,
            myPublicKey: currentPublicKey,
            decryptedText: text,
            fromSync: false,
            dialogIdentityOverride: targetDialogKey
        )
        DialogRepository.shared.updateDialogFromMessages(opponentKey: targetDialogKey)

        // Android parity: persist IMMEDIATELY after inserting outgoing message.
        // Without this, if app is killed within 800ms debounce window,
        // the message is lost forever (only in memory, not on disk).
        // Android saves to Room DB synchronously in sendMessage().
        MessageRepository.shared.persistNow()

        // Saved Messages: local-only, no server send
        if targetDialogKey == currentPublicKey {
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: targetDialogKey, status: .delivered)
            return
        }

        // Send via WebSocket (queued if offline, sent directly if online)
        ProtocolManager.shared.sendPacket(packet)

        // Server doesn't send delivery ACK (0x08) for group messages —
        // MessageDispatcher.sendGroup() only distributes to members, no ACK back.
        // Mark as delivered immediately (same pattern as Saved Messages).
        if DatabaseManager.isGroupDialogKey(targetDialogKey) {
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(
                messageId: messageId, opponentKey: targetDialogKey, status: .delivered
            )
        } else {
            registerOutgoingRetry(for: packet)
        }
    }

    /// Sends current user's avatar to a chat as a message attachment.
    /// Desktop parity: `onClickCamera()` in `DialogInput.tsx` → loads avatar → attaches as AVATAR type
    /// → `prepareAttachmentsToSend()` encrypts blob → uploads to transport → sends PacketMessage.
    /// Sends an avatar to a chat as a message attachment.
    /// - Parameter avatarSourceKey: Key to load avatar from. `nil` = personal avatar (currentPublicKey).
    ///   Pass groupDialogKey to send the group's avatar instead.
    func sendAvatar(toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "", avatarSourceKey: String? = nil) async throws {
        guard let privKey = privateKeyHex, let hash = privateKeyHash else {
            Self.logger.error("📤 Cannot send avatar — missing keys")
            throw CryptoError.decryptionFailed
        }

        let sourceKey = avatarSourceKey ?? currentPublicKey
        // Load avatar from local storage as base64 (desktop: avatars[0].avatar)
        guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: sourceKey) else {
            Self.logger.error("📤 No avatar to send (source=\(sourceKey.prefix(12))…)")
            return
        }

        let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
        let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
        let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
        let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)

        // Android/Desktop parity: avatar messages have empty text.
        // Desktop shows "$a=Avatar" in chat list ONLY if decrypted text is empty.
        // Group vs direct: different encryption paths (same as sendMessageWithAttachments).
        let attachmentPassword: String
        let encryptedContent: String
        let outChachaKey: String
        let outAesChachaKey: String
        let targetKey: String

        if isGroup {
            targetKey = Self.normalizedGroupDialogIdentity(toPublicKey)
            guard let groupKey = GroupRepository.shared.groupKey(
                account: currentPublicKey,
                privateKeyHex: privKey,
                groupDialogKey: targetKey
            ) else {
                throw CryptoError.invalidData("Missing group key for \(targetKey)")
            }
            encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                Data("".utf8), password: groupKey
            )
            // Desktop parity: Buffer.from(groupKey).toString('hex') — hex of UTF-8 bytes.
            // Desktop stores chacha_key = hex(groupKey) and uses it for attachment decryption.
            attachmentPassword = Data(groupKey.utf8).hexString
            outChachaKey = ""
            outAesChachaKey = ""
        } else {
            targetKey = toPublicKey
            let encrypted = try MessageCrypto.encryptOutgoing(
                plaintext: "",
                recipientPublicKeyHex: toPublicKey
            )
            // Attachment password: HEX encoding of raw 56-byte key+nonce.
            attachmentPassword = encrypted.plainKeyAndNonce.hexString
            encryptedContent = encrypted.content
            outChachaKey = encrypted.chachaKey

            // aesChachaKey = Latin-1 encoding (matches desktop sync chain)
            guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
                throw CryptoError.encryptionFailed
            }
            let aesChachaPayload = Data(latin1ForSync.utf8)
            outAesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                aesChachaPayload, password: privKey
            )
        }

        // Desktop parity: avatar blob is a full data URI (e.g. "data:image/png;base64,iVBOR...")
        let dataURI = "data:image/jpeg;base64,\(avatarBase64)"
        let avatarData = Data(dataURI.utf8)
        let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
            avatarData, password: attachmentPassword
        )

        // Cache avatar locally BEFORE upload so outgoing avatar shows instantly
        let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: sourceKey)
        if let avatarImage {
            AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId)
        }

        // BlurHash for preview (computed before upload so optimistic UI has it)
        let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""

        // Build packet with avatar attachment — preview will be updated with tag after upload
        var packet = PacketMessage()
        packet.fromPublicKey = currentPublicKey
        packet.toPublicKey = targetKey
        packet.content = encryptedContent
        packet.chachaKey = outChachaKey
        packet.timestamp = timestamp
        packet.privateKey = hash
        packet.messageId = messageId
        packet.aesChachaKey = outAesChachaKey
        packet.attachments = [
            MessageAttachment(
                id: attachmentId,
                preview: blurhash,
                blob: "",
                type: .avatar
            ),
        ]

        // Ensure dialog exists
        let dialogKey = isGroup ? targetKey : toPublicKey
        let existingDialog = DialogRepository.shared.dialogs[dialogKey]
        let groupMetadata = isGroup
            ? GroupRepository.shared.groupMetadata(account: currentPublicKey, groupDialogKey: targetKey)
            : nil
        let title = !opponentTitle.isEmpty
            ? opponentTitle
            : (existingDialog?.opponentTitle.isEmpty == false
                ? (existingDialog?.opponentTitle ?? "")
                : (groupMetadata?.title ?? ""))
        let username = !opponentUsername.isEmpty
            ? opponentUsername
            : (existingDialog?.opponentUsername.isEmpty == false
                ? (existingDialog?.opponentUsername ?? "")
                : (groupMetadata?.description ?? ""))
        DialogRepository.shared.ensureDialog(
            opponentKey: dialogKey, title: title, username: username, myPublicKey: currentPublicKey
        )

        // Optimistic UI — show message IMMEDIATELY (before upload)
        let storedPassword = isGroup ? attachmentPassword : ("rawkey:" + attachmentPassword)
        MessageRepository.shared.upsertFromMessagePacket(
            packet, myPublicKey: currentPublicKey, decryptedText: "",
            attachmentPassword: storedPassword,
            fromSync: false,
            dialogIdentityOverride: dialogKey
        )
        DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey)

        // Upload encrypted blob to transport server
        let upload: (tag: String, server: String)
        do {
            upload = try await attachmentFlowTransport.uploadFile(
                id: attachmentId, content: Data(encryptedBlob.utf8)
            )
        } catch {
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
            DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: dialogKey, status: .error)
            Self.logger.error("📤 Avatar upload failed: \(error.localizedDescription)")
            throw error
        }

        // Desktop parity: preview = pure blurhash, CDN tag in transportTag
        packet.attachments = [
            MessageAttachment(
                id: attachmentId,
                preview: blurhash,
                blob: "",
                type: .avatar,
                transportTag: upload.tag,
                transportServer: upload.server
            ),
        ]

        // Saved Messages — mark delivered locally but STILL send to server
        if toPublicKey == currentPublicKey {
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
            packetFlowSender.sendPacket(packet)
            Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(upload.tag)")
            return
        }

        packetFlowSender.sendPacket(packet)

        // Server doesn't ACK group messages — mark delivered immediately
        if isGroup {
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: dialogKey, status: .delivered)
        } else {
            registerOutgoingRetry(for: packet)
        }
        MessageRepository.shared.persistNow()
        Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(upload.tag)")
    }

    /// Sends a message with image/file attachments.
    ///
    /// Desktop parity: `prepareAttachmentsToSend()` in `DialogProvider.tsx` →
    /// for each attachment: encrypt blob → upload to transport → set preview = tag → clear blob.
    ///
    /// Flow per attachment:
    /// 1. Build data URI (desktop: `FileReader.readAsDataURL()`)
    /// 2. Encrypt with `encryptWithPasswordDesktopCompat(dataURI, password: latin1OfPlainKeyAndNonce)`
    /// 3. Upload encrypted blob to transport → get server tag
    /// 4. Set preview: IMAGE → `"tag::"`, FILE → `"tag::size::filename"`
    /// 5. Clear blob (not sent over WebSocket)
    func sendMessageWithAttachments(
        text: String,
        attachments: [PendingAttachment],
        toPublicKey: String,
        opponentTitle: String = "",
        opponentUsername: String = "",
        messageId: String? = nil
    ) async throws -> String {
        guard let privKey = privateKeyHex, let hash = privateKeyHash else {
            Self.logger.error("📤 Cannot send — missing keys")
            throw CryptoError.decryptionFailed
        }

        let messageId = messageId ?? UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
        let timestamp = Int64(Date().timeIntervalSince1970 * 1000)

        // Android parity: no caption → encrypt empty string "".
        // Receivers decrypt "" → show "Photo"/"File" in chat list.
        let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines)
        let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)

        // Group vs direct: different encryption paths.
        let attachmentPassword: String
        let encryptedContent: String
        let outChachaKey: String
        let outAesChachaKey: String

        if isGroup {
            let normalizedGroup = Self.normalizedGroupDialogIdentity(toPublicKey)
            guard let groupKey = GroupRepository.shared.groupKey(
                account: currentPublicKey,
                privateKeyHex: privKey,
                groupDialogKey: normalizedGroup
            ) else {
                throw CryptoError.invalidData("Missing group key for \(normalizedGroup)")
            }
            // Group: encrypt text and attachments with shared group key.
            encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                Data((messageText.isEmpty ? "" : messageText).utf8),
                password: groupKey
            )
            // Android parity: plain groupKey as PBKDF2 password.
            // Desktop SENDS with hex(UTF-8 bytes) but RECEIVES with plain — align with receive path.
            attachmentPassword = groupKey
            outChachaKey = ""
            outAesChachaKey = ""
        } else {
            let encrypted = try MessageCrypto.encryptOutgoing(
                plaintext: messageText.isEmpty ? "" : messageText,
                recipientPublicKeyHex: toPublicKey
            )
            // Attachment password: HEX encoding of raw 56-byte key+nonce.
            // Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
            attachmentPassword = encrypted.plainKeyAndNonce.hexString
            encryptedContent = encrypted.content
            outChachaKey = encrypted.chachaKey

            guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
                throw CryptoError.encryptionFailed
            }
            let aesChachaPayload = Data(latin1String.utf8)
            outAesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                aesChachaPayload,
                password: privKey
            )
        }

        #if DEBUG
        let pwdUtf8Bytes = Array(attachmentPassword.utf8)
        let pbkdf2Key = CryptoPrimitives.pbkdf2(
            password: attachmentPassword, salt: "rosetta", iterations: 1000,
            keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
        )
        Self.logger.debug("📎 rawKey (hex password): \(attachmentPassword)")
        Self.logger.debug("📎 pwdUTF8(\(pwdUtf8Bytes.count)b): \(Data(pwdUtf8Bytes).hexString)")
        Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)")
        #endif

        // Phase 1: Encrypt all attachments sequentially (same password, CPU-bound).
        struct EncryptedAttachment {
            let original: PendingAttachment
            let encryptedData: Data
            let preview: String // partially built — tag placeholder
        }
        var encryptedAttachments: [EncryptedAttachment] = []
        for attachment in attachments {
            let dataURI = buildDataURI(attachment)
            let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                Data(dataURI.utf8),
                password: attachmentPassword
            )

            #if DEBUG
            // Self-test: decrypt with the SAME WHATWG password.
            if let selfTestData = try? CryptoManager.shared.decryptWithPassword(
                encryptedBlob, password: attachmentPassword, requireCompression: true
            ), String(data: selfTestData, encoding: .utf8)?.hasPrefix("data:") == true {
                Self.logger.debug("📎 Blob self-test PASSED for \(attachment.id)")
            } else {
                Self.logger.error("📎 Blob self-test FAILED for \(attachment.id)")
            }
            #endif

            // Pre-compute blurhash/preview prefix (everything except tag)
            let previewSuffix: String
            switch attachment.type {
            case .image:
                // Plain blurhash only — NO dimension suffix.
                // Desktop's blurhash decoder (woltapp/blurhash) does strict length validation;
                // appending "|WxH" causes it to throw → no preview on desktop.
                // Android also sends plain blurhash without dimensions.
                previewSuffix = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? ""
            case .file:
                previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
            case .voice:
                // Voice preview: "duration::waveform_base64"
                previewSuffix = attachment.voicePreview ?? ""
            default:
                previewSuffix = ""
            }

            encryptedAttachments.append(EncryptedAttachment(
                original: attachment,
                encryptedData: Data(encryptedBlob.utf8),
                preview: previewSuffix
            ))
        }

        // Android parity: cache original images and show optimistic message BEFORE upload.
        // Android: addMessageSafely(optimisticMessage) → then background upload.
        // Without this, photo doesn't appear until upload completes (5-10 seconds).
        for item in encryptedAttachments {
            if item.original.type == .image, let image = UIImage(data: item.original.data) {
                AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id)
            } else if item.original.type == .file || item.original.type == .voice {
                AttachmentCache.shared.saveFile(
                    item.original.data,
                    forAttachmentId: item.original.id,
                    fileName: item.original.fileName ?? "file"
                )
            }
        }

        // Build placeholder attachments (no tag yet — filled after upload).
        // preview = "::blurhash" (no tag prefix), blob = "".
        let placeholderAttachments = encryptedAttachments.map { item in
            MessageAttachment(
                id: item.original.id,
                preview: item.preview.isEmpty ? "" : "::\(item.preview)",
                blob: "",
                type: item.original.type
            )
        }

        // ── Optimistic UI: show message INSTANTLY with placeholder attachments ──
        // Android parity: addMessageSafely(optimisticMessage) before upload.
        var optimisticPacket = PacketMessage()
        optimisticPacket.fromPublicKey = currentPublicKey
        optimisticPacket.toPublicKey = isGroup ? Self.normalizedGroupDialogIdentity(toPublicKey) : toPublicKey
        optimisticPacket.content = encryptedContent
        optimisticPacket.chachaKey = outChachaKey
        optimisticPacket.timestamp = timestamp
        optimisticPacket.privateKey = hash
        optimisticPacket.messageId = messageId
        optimisticPacket.aesChachaKey = outAesChachaKey
        optimisticPacket.attachments = placeholderAttachments

        let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
        let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
        let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
        DialogRepository.shared.ensureDialog(
            opponentKey: toPublicKey, title: title, username: username, myPublicKey: currentPublicKey
        )

        let displayText = messageText

        // Android parity: always insert as WAITING (fromSync: false).
        // Retry mechanism handles timeout (80s → ERROR).
        let optimisticDialogKey = isGroup ? Self.normalizedGroupDialogIdentity(toPublicKey) : toPublicKey
        let optimisticAttachmentPwd = isGroup ? attachmentPassword : ("rawkey:" + attachmentPassword)
        MessageRepository.shared.upsertFromMessagePacket(
            optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText,
            attachmentPassword: optimisticAttachmentPwd, fromSync: false
        )
        DialogRepository.shared.updateDialogFromMessages(opponentKey: optimisticDialogKey)
        MessageRepository.shared.persistNow()

        if toPublicKey == currentPublicKey {
            // Keep self/saved files immediately openable without transport tag/download.
            for item in encryptedAttachments where item.original.type == .file {
                let fallback = AttachmentPreviewCodec.parseFilePreview(
                    item.preview,
                    fallbackFileName: item.original.fileName ?? "file",
                    fallbackFileSize: item.original.fileSize ?? item.original.data.count
                )
                _ = AttachmentCache.shared.saveFile(
                    item.original.data,
                    forAttachmentId: item.original.id,
                    fileName: fallback.fileName
                )
            }
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
            return messageId
        }

        // ── Phase 2: Upload in background, then send packet ──
        // Wrapped in do/catch: if CDN upload fails, mark message as .error
        // so retryWaitingOutgoingMessagesAfterReconnect() doesn't pick it up
        // and send a text-only packet (which causes empty messages on recipient).
        do {
            let flowTransport = attachmentFlowTransport
            let totalAttachments = encryptedAttachments.count
            let capturedMessageId = messageId
            let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
                of: (Int, String, String).self
            ) { group in
                for (index, item) in encryptedAttachments.enumerated() {
                    group.addTask {
                        let result = try await flowTransport.uploadFile(
                            id: item.original.id, content: item.encryptedData,
                            onProgress: { progress in
                                // Approximate: each attachment contributes equally to overall progress
                                let perAttachment = 1.0 / Double(totalAttachments)
                                let base = Double(index) * perAttachment
                                let combined = base + progress * perAttachment
                                TransportManager.uploadProgress[capturedMessageId] = combined
                                NotificationCenter.default.post(
                                    name: TransportManager.uploadProgressNotification,
                                    object: capturedMessageId,
                                    userInfo: ["progress": combined]
                                )
                            }
                        )
                        return (index, result.tag, result.server)
                    }
                }
                var uploads = [Int: (tag: String, server: String)]()
                for try await (index, tag, server) in group { uploads[index] = (tag, server) }
                return encryptedAttachments.enumerated().map { index, item in
                    let upload = uploads[index] ?? (tag: "", server: "")
                    // Desktop parity: preview = payload only (no tag prefix).
                    // Desktop MessageFile.tsx does preview.split("::")[0] for filesize —
                    // embedding tag prefix makes it parse the UUID as filesize → shows wrong filename.
                    // CDN tag stored in transportTag for download.
                    let preview = item.preview
                    Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(upload.tag), server=\(upload.server)")
                    return MessageAttachment(
                        id: item.original.id, preview: preview, blob: "",
                        type: item.original.type,
                        transportTag: upload.tag, transportServer: upload.server
                    )
                }
            }

            // Signal upload completion → ring goes to 100% and hides.
            // Post BEFORE clearing dict so observer gets the value via userInfo.
            NotificationCenter.default.post(
                name: TransportManager.uploadProgressNotification,
                object: messageId,
                userInfo: ["progress": 1.0, "completed": true]
            )
            TransportManager.uploadProgress.removeValue(forKey: messageId)

            // Bail out if send was cancelled during upload
            if cancelledOutgoingIds.remove(messageId) != nil {
                Self.logger.info("📤 Send cancelled for \(messageId.prefix(8))…")
                return messageId
            }

            // Update message with real attachment tags (preview with CDN tag)
            MessageRepository.shared.updateAttachments(messageId: messageId, attachments: messageAttachments)

            // Build final packet for WebSocket send
            var packet = optimisticPacket
            packet.attachments = messageAttachments

            packetFlowSender.sendPacket(packet)
            if isGroup {
                MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
                DialogRepository.shared.updateDeliveryStatus(
                    messageId: messageId, opponentKey: optimisticDialogKey, status: .delivered
                )
            } else {
                registerOutgoingRetry(for: packet)
            }
            MessageRepository.shared.persistNow()
            Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…")
            return messageId
        } catch {
            // CDN upload or packet send failed — mark as .error to show failure to user.
            // Note: retryWaitingOutgoingMessagesAfterReconnect() may still pick up .error
            // messages within 80s, but the retry logic now checks for uploaded CDN tags
            // and skips messages with placeholder-only attachments.
            Self.logger.error("📤 CDN upload/send failed for \(messageId.prefix(8))…: \(error.localizedDescription)")
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
            DialogRepository.shared.updateDeliveryStatus(
                messageId: messageId, opponentKey: optimisticDialogKey, status: .error
            )
            MessageRepository.shared.persistNow()
            throw error
        }
    }

    /// Cancel an in-flight outgoing message send (CDN upload + WebSocket).
    func cancelOutgoingSend(messageId: String) {
        cancelledOutgoingIds.insert(messageId)
        TransportManager.uploadProgress.removeValue(forKey: messageId)
    }

    /// Builds a data URI from attachment data (desktop: `FileReader.readAsDataURL()`).
    /// Voice attachments use hex encoding (Desktop parity: `Buffer.toString('hex')`).
    private func buildDataURI(_ attachment: PendingAttachment) -> String {
        switch attachment.type {
        case .image:
            return "data:image/jpeg;base64,\(attachment.data.base64EncodedString())"
        case .file:
            let mimeType = mimeTypeForFileName(attachment.fileName ?? "file")
            return "data:\(mimeType);base64,\(attachment.data.base64EncodedString())"
        case .voice:
            // Desktop parity: raw bytes as hex string.
            // Desktop DialogInput.tsx:214 sends Buffer.from(audioBlob).toString('hex'),
            // Desktop MessageVoice.tsx:125 reads Buffer.from(blob, "hex").
            return attachment.data.hexString
        default:
            return "data:application/octet-stream;base64,\(attachment.data.base64EncodedString())"
        }
    }

    /// Returns MIME type for a file name based on extension.
    private func mimeTypeForFileName(_ name: String) -> String {
        let ext = (name as NSString).pathExtension.lowercased()
        switch ext {
        case "jpg", "jpeg": return "image/jpeg"
        case "png": return "image/png"
        case "gif": return "image/gif"
        case "pdf": return "application/pdf"
        case "zip": return "application/zip"
        case "mp4": return "video/mp4"
        case "mp3": return "audio/mpeg"
        case "doc", "docx": return "application/msword"
        case "txt": return "text/plain"
        default: return "application/octet-stream"
        }
    }

    /// Sends a message with a reply/forward blob (AttachmentType.messages).
    ///
    /// Desktop parity: `prepareAttachmentsToSend()` in `DialogProvider.tsx` →
    /// for MESSAGES type: `encodeWithPassword(chacha_key_utf8, JSON.stringify(reply))`.
    /// Android: `ChatViewModel.sendMessageWithReply()`.
    ///
    /// The reply blob is a JSON array of `ReplyMessageData` objects, encrypted with
    /// `encryptWithPasswordDesktopCompat()` using the WHATWG UTF-8 of plainKeyAndNonce.
    func sendMessageWithReply(
        text: String,
        replyMessages: [ReplyMessageData],
        toPublicKey: String,
        opponentTitle: String = "",
        opponentUsername: String = ""
    ) async throws {
        guard let privKey = privateKeyHex, let hash = privateKeyHash else {
            Self.logger.error("📤 Cannot send reply — missing keys")
            throw CryptoError.decryptionFailed
        }

        let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
        let timestamp = Int64(Date().timeIntervalSince1970 * 1000)

        // Forward messages have empty text — only the MESSAGES attachment carries content.
        let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines)

        // Build the reply JSON blob (shared by both group and direct paths)
        let replyJSON = try JSONEncoder().encode(replyMessages)
        guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else {
            throw CryptoError.encryptionFailed
        }

        let replyAttachmentId = "reply_\(timestamp)"

        // --- Group path: encrypt with groupKey, no ECDH ---
        let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
        if isGroup {
            let normalizedTarget = Self.normalizedGroupDialogIdentity(toPublicKey)
            guard let groupKey = GroupRepository.shared.groupKey(
                account: currentPublicKey,
                privateKeyHex: privKey,
                groupDialogKey: normalizedTarget
            ) else {
                throw CryptoError.invalidData("Missing group key for \(normalizedTarget)")
            }

            // Desktop parity: text encrypted with plain groupKey
            let encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                Data(messageText.utf8),
                password: groupKey
            )

            // Android parity: plain groupKey as PBKDF2 password for reply/forward blobs.
            let encryptedReplyBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                replyJSON,
                password: groupKey
            )

            let replyAttachment = MessageAttachment(
                id: replyAttachmentId, preview: "", blob: encryptedReplyBlob, type: .messages
            )

            // Group packets: no ECDH keys
            var packet = PacketMessage()
            packet.fromPublicKey = currentPublicKey
            packet.toPublicKey = normalizedTarget
            packet.content = encryptedContent
            packet.chachaKey = ""
            packet.timestamp = timestamp
            packet.privateKey = hash
            packet.messageId = messageId
            packet.aesChachaKey = ""
            packet.attachments = [replyAttachment]

            // Local copy with decrypted blob for UI
            let localReplyAttachment = MessageAttachment(
                id: replyAttachmentId, preview: "", blob: replyJSONString, type: .messages
            )
            var localPacket = packet
            localPacket.attachments = [localReplyAttachment]

            let existingDialog = DialogRepository.shared.dialogs[normalizedTarget]
            let groupMeta = GroupRepository.shared.groupMetadata(
                account: currentPublicKey, groupDialogKey: normalizedTarget
            )
            let title = !opponentTitle.isEmpty ? opponentTitle
                : (existingDialog?.opponentTitle.isEmpty == false ? existingDialog!.opponentTitle
                    : (groupMeta?.title ?? ""))
            let username = !opponentUsername.isEmpty ? opponentUsername
                : (existingDialog?.opponentUsername ?? "")
            DialogRepository.shared.ensureDialog(
                opponentKey: normalizedTarget, title: title, username: username,
                myPublicKey: currentPublicKey
            )

            MessageRepository.shared.upsertFromMessagePacket(
                localPacket, myPublicKey: currentPublicKey,
                decryptedText: messageText, attachmentPassword: groupKey,
                fromSync: false, dialogIdentityOverride: normalizedTarget
            )
            DialogRepository.shared.updateDialogFromMessages(opponentKey: normalizedTarget)

            packetFlowSender.sendPacket(packet)
            // Server does NOT send delivery ACK for group messages
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(
                messageId: messageId, opponentKey: normalizedTarget, status: .delivered
            )
            MessageRepository.shared.persistNow()
            Self.logger.info("📤 Group reply sent to \(normalizedTarget.prefix(12))… with \(replyMessages.count) quoted message(s)")
            return
        }

        // --- Direct message path: ECDH encryption ---
        let encrypted = try MessageCrypto.encryptOutgoing(
            plaintext: messageText,
            recipientPublicKeyHex: toPublicKey
        )

        // Reply password: HEX encoding of raw 56-byte key+nonce.
        // Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
        let replyPassword = encrypted.plainKeyAndNonce.hexString

        // Desktop commit aaa4b42: no re-upload needed for forwards.
        // chacha_key_plain in ReplyMessageData carries the original message's key,
        // so the recipient can decrypt original CDN blobs directly.

        // Encrypt reply blob with desktop-compatible encryption
        let encryptedReplyBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
            replyJSON,
            password: replyPassword
        )

        // Build reply attachment
        let replyAttachment = MessageAttachment(
            id: replyAttachmentId,
            preview: "",
            blob: encryptedReplyBlob,
            type: .messages
        )

        // Build aesChachaKey (same as sendMessageWithAttachments)
        guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
            throw CryptoError.encryptionFailed
        }
        let aesChachaPayload = Data(latin1ForSync.utf8)
        let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
            aesChachaPayload,
            password: privKey
        )

        // Build packet for wire (encrypted blob)
        var packet = PacketMessage()
        packet.fromPublicKey = currentPublicKey
        packet.toPublicKey = toPublicKey
        packet.content = encrypted.content
        packet.chachaKey = encrypted.chachaKey
        packet.timestamp = timestamp
        packet.privateKey = hash
        packet.messageId = messageId
        packet.aesChachaKey = aesChachaKey
        packet.attachments = [replyAttachment]

        // Build a local copy with decrypted blob for UI storage
        // (incoming messages get decrypted in handleIncomingMessage; outgoing must be pre-decrypted)
        let localReplyAttachment = MessageAttachment(
            id: replyAttachmentId,
            preview: "",
            blob: replyJSONString,  // Decrypted JSON for local rendering
            type: .messages
        )
        var localPacket = packet
        localPacket.attachments = [localReplyAttachment]

        // Ensure dialog exists
        let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
        let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
        let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
        DialogRepository.shared.ensureDialog(
            opponentKey: toPublicKey,
            title: title,
            username: username,
            myPublicKey: currentPublicKey
        )

        // Optimistic UI update — use localPacket (decrypted blob) for storage.
        // Android parity: always insert as WAITING (fromSync: false).
        MessageRepository.shared.upsertFromMessagePacket(
            localPacket,
            myPublicKey: currentPublicKey,
            decryptedText: messageText,
            attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
            fromSync: false
        )
        DialogRepository.shared.updateDialogFromMessages(opponentKey: localPacket.toPublicKey)

        // Saved Messages: local-only
        if toPublicKey == currentPublicKey {
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(
                messageId: messageId, opponentKey: toPublicKey, status: .delivered
            )
            return
        }

        packetFlowSender.sendPacket(packet)
        registerOutgoingRetry(for: packet)
        MessageRepository.shared.persistNow()
        Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
    }

    /// Sends a call event message (AttachmentType.call, type=4) to dialog history.
    /// Desktop/Android parity: caller emits one attachment with `preview = durationSec`.
    func sendCallAttachment(
        toPublicKey: String,
        durationSec: Int,
        opponentTitle: String = "",
        opponentUsername: String = ""
    ) async throws {
        guard let privKey = privateKeyHex, let hash = privateKeyHash else {
            Self.logger.error("📤 Cannot send call attachment — missing keys")
            throw CryptoError.decryptionFailed
        }

        let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
        let timestamp = Int64(Date().timeIntervalSince1970 * 1000)

        let encrypted = try MessageCrypto.encryptOutgoing(
            plaintext: "",
            recipientPublicKeyHex: toPublicKey
        )

        guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
            throw CryptoError.encryptionFailed
        }
        let aesChachaPayload = Data(latin1ForSync.utf8)
        let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
            aesChachaPayload,
            password: privKey
        )

        var packet = PacketMessage()
        packet.fromPublicKey = currentPublicKey
        packet.toPublicKey = toPublicKey
        packet.content = encrypted.content
        packet.chachaKey = encrypted.chachaKey
        packet.timestamp = timestamp
        packet.privateKey = hash
        packet.messageId = messageId
        packet.aesChachaKey = aesChachaKey
        packet.attachments = [
            MessageAttachment(
                id: String(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)),
                preview: String(max(durationSec, 0)),
                blob: "",
                type: .call
            ),
        ]

        let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
        let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
        let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
        DialogRepository.shared.ensureDialog(
            opponentKey: toPublicKey,
            title: title,
            username: username,
            myPublicKey: currentPublicKey
        )

        MessageRepository.shared.upsertFromMessagePacket(
            packet,
            myPublicKey: currentPublicKey,
            decryptedText: "",
            fromSync: false
        )
        DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey)
        MessageRepository.shared.persistNow()

        if toPublicKey == currentPublicKey {
            MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
            DialogRepository.shared.updateDeliveryStatus(
                messageId: messageId,
                opponentKey: toPublicKey,
                status: .delivered
            )
            return
        }

        packetFlowSender.sendPacket(packet)
        registerOutgoingRetry(for: packet)
    }

    /// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
    func sendTypingIndicator(toPublicKey: String) {
        let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
        let normalized = DatabaseManager.isGroupDialogKey(base)
            ? Self.normalizedGroupDialogIdentity(base)
            : base
        guard normalized != currentPublicKey,
              !normalized.isEmpty,
              let hash = privateKeyHash,
              ProtocolManager.shared.connectionState == .authenticated
        else { return }

        let now = Int64(Date().timeIntervalSince1970 * 1000)
        let lastSent = lastTypingSentAt[normalized] ?? 0
        if now - lastSent < ProtocolConstants.typingThrottleMs {
            return
        }
        lastTypingSentAt[normalized] = now

        var packet = PacketTyping()
        packet.privateKey = hash
        packet.fromPublicKey = currentPublicKey
        packet.toPublicKey = normalized
        ProtocolManager.shared.sendPacket(packet)
    }

    /// Android parity: sends read receipt for direct/group dialog.
    /// Uses timestamp dedup (not time-based throttle) — only sends if latest incoming
    /// message timestamp > last sent read receipt timestamp. Retries once after 2s.
    /// NOTE: No connectionState guard — sendPacket() auto-enqueues if not authenticated,
    /// flushPacketQueue() sends on next handshake. Silent return was root cause of
    /// read receipts lost after airplane-mode reconnect (UI updated but server never notified).
    func sendReadReceipt(toPublicKey: String) {
        let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
        let normalized = DatabaseManager.isGroupDialogKey(base)
            ? Self.normalizedGroupDialogIdentity(base)
            : base
        let shortKey = String(normalized.prefix(8))
        guard normalized != currentPublicKey,
              !normalized.isEmpty,
              !SystemAccounts.isSystemAccount(normalized),
              let hash = privateKeyHash
        else {
            Self.logger.debug("📖 ReadReceipt skip — guard fail for \(shortKey) (self=\(normalized == self.currentPublicKey), empty=\(normalized.isEmpty), hash=\(self.privateKeyHash != nil))")
            return
        }

        let connState = ProtocolManager.shared.connectionState
        // Android parity: timestamp dedup — only send if latest incoming message
        // timestamp is newer than what we already sent a receipt for.
        let latestTs = MessageRepository.shared.latestIncomingTimestamp(
            for: normalized, myPublicKey: currentPublicKey
        ) ?? 0
        let lastSentTs = lastReadReceiptTimestamp[normalized] ?? 0
        if latestTs > 0, latestTs <= lastSentTs {
            Self.logger.debug("📖 ReadReceipt skip — dedup for \(shortKey) (latest=\(latestTs), lastSent=\(lastSentTs))")
            return
        }

        Self.logger.info("📖 ReadReceipt → \(shortKey) (ts=\(latestTs), conn=\(String(describing: connState)))")

        var packet = PacketRead()
        packet.privateKey = hash
        packet.fromPublicKey = currentPublicKey
        packet.toPublicKey = normalized
        ProtocolManager.shared.sendPacket(packet)
        lastReadReceiptTimestamp[normalized] = latestTs

        // Android parity: retry once after 2 seconds in case send failed silently.
        Task { @MainActor [weak self] in
            try? await Task.sleep(for: .seconds(2))
            guard let self,
                  let hash = self.privateKeyHash else { return }
            Self.logger.debug("📖 ReadReceipt retry → \(shortKey) (conn=\(String(describing: ProtocolManager.shared.connectionState)))")
            var retryPacket = PacketRead()
            retryPacket.privateKey = hash
            retryPacket.fromPublicKey = self.currentPublicKey
            retryPacket.toPublicKey = normalized
            ProtocolManager.shared.sendPacket(retryPacket)
        }
    }

    /// Updates locally cached display name and username (called from ProfileEditView).
    func updateDisplayNameAndUsername(displayName: String, username: String) {
        self.displayName = displayName
        self.username = username
    }

    /// Ends the session and disconnects.
    func endSession() {
        // Unsubscribe push tokens from server BEFORE disconnecting.
        if let voipToken = UserDefaults.standard.string(forKey: "voip_push_token"),
           !voipToken.isEmpty {
            unsubscribeVoIPToken(voipToken)
        }
        if let fcmToken = UserDefaults.standard.string(forKey: "apns_device_token"),
           !fcmToken.isEmpty {
            unsubscribeFCMToken(fcmToken)
        }
        ProtocolManager.shared.disconnect()
        SessionCredentialsManager.shared.clear()
        privateKeyHash = nil
        privateKeyHex = nil
        lastTypingSentAt.removeAll()
        repositoriesReady = false
        syncBatchInProgress = false
        syncRequestInFlight = false
        pendingSyncReads.removeAll()
        pendingOpponentReads.removeAll()
        pendingIncomingMessages.removeAll()
        isProcessingIncomingMessages = false
        malformedMessageResyncTask?.cancel()
        malformedMessageResyncTask = nil
        malformedMessageResyncQueued = false
        #if DEBUG
        malformedMessageResyncTestHook = nil
        malformedMessageResyncTriggerCount = 0
        #endif
        lastReadReceiptTimestamp.removeAll()
        requestedUserInfoKeys.removeAll()
        pendingOutgoingRetryTasks.values.forEach { $0.cancel() }
        pendingOutgoingRetryTasks.removeAll()
        pendingOutgoingPackets.removeAll()
        pendingOutgoingAttempts.removeAll()
        lastPushTokenSubscribe = nil
        lastVoIPTokenSubscribe = nil
        isAuthenticated = false
        currentPublicKey = ""
        displayName = ""
        username = ""
        DialogRepository.shared.reset()
        MessageRepository.shared.reset()
        DatabaseManager.shared.close()
        CryptoManager.shared.clearCaches()
        GroupRepository.shared.clearCaches()
        AttachmentCache.shared.privateKey = nil
        RecentSearchesRepository.shared.clearSession()
        DraftManager.shared.reset()
        CallManager.shared.resetForSessionEnd()
    }

    // MARK: - Protocol Callbacks

    private func setupProtocolCallbacks() {
        let proto = ProtocolManager.shared

        proto.onMessageReceived = { [weak self] packet in
            guard let self else { return }
            Task { @MainActor [weak self] in
                self?.enqueueIncomingMessage(packet)
            }
        }

        proto.onMalformedMessageReceived = { [weak self] info in
            guard let self else { return }
            Task { @MainActor [weak self] in
                self?.handleMalformedMessagePacket(info)
            }
        }

        proto.onDeliveryReceived = { [weak self] packet in
            Task { @MainActor in
                let opponentKey = MessageRepository.shared.dialogKey(forMessageId: packet.messageId)
                    ?? packet.toPublicKey
                // Desktop parity: update both status AND timestamp on delivery ACK.
                let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
                MessageRepository.shared.updateDeliveryStatus(
                    messageId: packet.messageId,
                    status: .delivered,
                    newTimestamp: deliveryTimestamp
                )
                // Android parity 1:1: full dialog recalculation after delivery status change.
                DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
                self?.resolveOutgoingRetry(messageId: packet.messageId)
                // Desktop parity (useDialogFiber.ts): update sync cursor on delivery ACK.
                if let self, !self.syncBatchInProgress {
                    self.saveLastSyncTimestamp(deliveryTimestamp)
                }
            }
        }

        proto.onReadReceived = { [weak self] packet in
            guard let self else { return }
            Task { @MainActor in
                guard let context = Self.resolveReadPacketContext(packet, ownKey: self.currentPublicKey) else {
                    Self.logger.debug(
                        "Skipping unsupported read packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)"
                    )
                    return
                }

                let ownKey = self.currentPublicKey
                let opponentKey = context.dialogKey

                if context.fromMe {
                    // Android parity: read sync from another own device means
                    // incoming messages in this dialog should become read locally.
                    MessageRepository.shared.markIncomingAsRead(
                        opponentKey: opponentKey,
                        myPublicKey: ownKey
                    )
                    DialogRepository.shared.markAsRead(opponentKey: opponentKey)

                    // Desktop-active suppression: prevent in-app banner for 30s.
                    self.desktopActiveDialogs[opponentKey] = Date()

                    // Dismiss in-app banner if it's showing for this dialog.
                    InAppBannerManager.shared.dismissIfMatchingDialog(opponentKey)

                    // Clear delivered push notifications for this dialog + decrement badge.
                    Self.clearDeliveredNotifications(for: opponentKey)

                    // Write to App Group so NSE also suppresses pushes for this dialog.
                    if let shared = UserDefaults(suiteName: "group.com.rosetta.dev") {
                        let now = Date().timeIntervalSince1970
                        var recentlyRead = shared.dictionary(forKey: "nse_recently_read_dialogs") as? [String: Double] ?? [:]
                        recentlyRead[opponentKey] = now
                        recentlyRead = recentlyRead.filter { now - $0.value < 60 }
                        shared.set(recentlyRead, forKey: "nse_recently_read_dialogs")
                    }

                    // Race fix: if sync is in progress, the messages may not be
                    // in DB yet (PacketRead arrives before sync messages).
                    // Store for re-application at BATCH_END.
                    // BUT: detect self-echo — server buffers OUR OWN PacketRead and
                    // replays it during sync. If dialog exists + all messages already
                    // read, this is our own echo. Skipping pendingSyncReads prevents
                    // marking NEWLY ARRIVED (unread) messages as read.
                    if self.syncBatchInProgress {
                        let dialogExists = DialogRepository.shared.dialogs[opponentKey] != nil
                        let allRead = MessageRepository.shared.countUnread(
                            account: ownKey, opponentKey: opponentKey
                        ) == 0
                        if !(dialogExists && allRead) {
                            self.pendingSyncReads.insert(opponentKey)
                        }
                    }
                    return
                }

                // Order matters: update DB first, THEN read from DB for dialog.
                // Without this order, DialogRepository reads stale is_read=0.
                MessageRepository.shared.markOutgoingAsRead(
                    opponentKey: opponentKey,
                    myPublicKey: ownKey
                )
                DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey)
                // Race fix: if sync is in progress, messages may not be in DB yet.
                // markOutgoingAsRead() may have updated 0 rows. Store for re-application
                // at BATCH_END after waitForInboundQueueToDrain() ensures all messages
                // are committed. Android doesn't need this — single FIFO queue.
                if self.syncBatchInProgress {
                    self.pendingOpponentReads.insert(opponentKey)
                }
                // Resolve pending retry timers for all messages to this opponent —
                // read receipt proves delivery, no need to retry further.
                self.resolveAllOutgoingRetries(toPublicKey: opponentKey)
                // Desktop parity (useDialogFiber.ts): update sync cursor on read receipt.
                if !self.syncBatchInProgress {
                    let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
                    self.saveLastSyncTimestamp(nowMs)
                }
            }
        }

        proto.onTypingReceived = { [weak self] packet in
            guard let self else { return }
            Task { @MainActor in
                guard let context = Self.resolveTypingPacketContext(packet, ownKey: self.currentPublicKey) else {
                    return
                }
                if context.fromMe { return }
                MessageRepository.shared.markTyping(from: context.dialogKey, senderKey: context.fromKey)
            }
        }

        proto.onOnlineStateReceived = { packet in
            Task { @MainActor in
                // PERF: Batch update — single observation trigger + single DB transaction
                // instead of N mutations + N persistDialog calls per entry.
                DialogRepository.shared.batchUpdateOnlineState(
                    entries: packet.entries.map { ($0.publicKey, $0.isOnline) }
                )
            }
        }

        proto.onUserInfoReceived = { [weak self] packet in
            guard let self else { return }
            Task { @MainActor in
                Self.logger.debug("UserInfo received: username='\(packet.username)', title='\(packet.title)'")
                if !packet.title.isEmpty {
                    self.displayName = packet.title
                    AccountManager.shared.updateProfile(displayName: packet.title, username: nil)
                }
                if !packet.username.isEmpty {
                    self.username = packet.username
                    AccountManager.shared.updateProfile(displayName: nil, username: packet.username)
                }
                NotificationCenter.default.post(name: .profileDidUpdate, object: nil)
            }
        }

        // Note: onSearchResult is set by ChatListViewModel for user search.
        // SessionManager does NOT override it — the ViewModel handles both
        // displaying results and updating dialog user info.

        proto.onHandshakeCompleted = { [weak self] _ in
            guard let self else { return }
            Task { @MainActor in
                Self.logger.info("Handshake completed")

                // Wait for repositories if connect() was started early (Phase 4 optimization).
                // TCP+TLS (200-500ms) almost always exceeds repository bootstrap (50-150ms),
                // so this loop rarely executes. 10s safety timeout prevents infinite wait.
                if !self.repositoriesReady {
                    Self.logger.info("⏳ Waiting for repositories to finish bootstrap...")
                    var waitCount = 0
                    while !self.repositoriesReady {
                        waitCount += 1
                        if waitCount > 1000 { // 10s safety timeout (1000 × 10ms)
                            Self.logger.error("Repository bootstrap timeout — proceeding anyway")
                            break
                        }
                        try? await Task.sleep(for: .milliseconds(10))
                    }
                    if waitCount > 0 {
                        Self.logger.info("⏱ Repository wait: \(waitCount * 10)ms")
                    }
                }

                guard let hash = self.privateKeyHash else { return }

                // Only send UserInfo if we have profile data to update
                let name = self.displayName
                let uname = self.username
                if !name.isEmpty || !uname.isEmpty {
                    var userInfoPacket = PacketUserInfo()
                    userInfoPacket.username = uname
                    userInfoPacket.title = name
                    userInfoPacket.privateKey = hash
                    ProtocolManager.shared.sendPacket(userInfoPacket)
                } else {
                    // No local profile — fetch from server (e.g. after import via seed phrase).
                    // Server may have our profile from a previous session on another device.
                    Self.logger.debug("Skipping UserInfo send — requesting own profile from server")
                    var searchPacket = PacketSearch()
                    searchPacket.privateKey = hash
                    searchPacket.search = self.currentPublicKey
                    ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
                }

                // Reset sync state — if a previous connection dropped mid-sync,
                // syncRequestInFlight would stay true and block all future syncs.
                self.syncRequestInFlight = false
                self.syncBatchInProgress = false
                self.pendingIncomingMessages.removeAll()
                self.enqueuedMessageIds.removeAll()
                self.isProcessingIncomingMessages = false

                // Cancel stale retry timers from previous connection —
                // they would fire and duplicate-send messages that are about to be retried fresh.
                self.pendingOutgoingRetryTasks.values.forEach { $0.cancel() }
                self.pendingOutgoingRetryTasks.removeAll()
                self.pendingOutgoingPackets.removeAll()
                self.pendingOutgoingAttempts.removeAll()

                // Desktop parity: request message synchronization after authentication.
                self.requestSynchronize()
                // Safety net: reconcile dialog delivery indicators and unread counts
                // with actual message statuses, fixing any desync from stale retry timers.
                // PERF: Single call — reconcileAllDialogs handles both.
                DialogRepository.shared.reconcileAllDialogs()
                // NOTE: retryWaitingOutgoingMessages moved to NOT_NEEDED (Android parity:
                // finishSyncCycle() retries AFTER sync completes, not during).

                // Clear dedup sets on reconnect so subscriptions can be re-established lazily.
                self.requestedUserInfoKeys.removeAll()
                self.onlineSubscribedKeys.removeAll()

                // Reset read receipt dedup — previous sends may have been lost if the
                // TCP connection was silently broken. Without this, timestamp dedup
                // blocks re-sending for dialogs where the receipt was "sent" but never
                // actually reached the server (airplane mode → reconnect scenario).
                let prevDedup = self.lastReadReceiptTimestamp.count
                self.lastReadReceiptTimestamp.removeAll()
                if prevDedup > 0 {
                    Self.logger.info("📖 Cleared \(prevDedup) read receipt dedup entries on reconnect")
                }

                // Re-send read receipts for dialogs currently open and read-eligible.
                // Handles the case where user opened a chat during reconnect, local UI
                // updated (markIncomingAsRead), but PacketRead was enqueued/lost.
                let eligibleKeys = MessageRepository.shared.activeDialogKeys.filter {
                    MessageRepository.shared.isDialogReadEligible($0)
                }
                if !eligibleKeys.isEmpty {
                    Self.logger.info("📖 Re-sending read receipts for \(eligibleKeys.count) active dialog(s) after reconnect")
                }
                for dialogKey in eligibleKeys {
                    self.sendReadReceipt(toPublicKey: dialogKey)
                }

                // Send push tokens to server for push notifications (Android parity).
                self.sendPushTokenToServer()
                self.sendVoIPTokenToServer()
                CallManager.shared.onAuthenticated()

                // Desktop parity: user info refresh is deferred until sync completes.
                // Desktop fetches lazily per-component (useUserInformation); we do it
                // in bulk after sync ends to avoid flooding the server during sync streaming.
            }
        }

        proto.onSyncReceived = { [weak self] packet in
            guard let self else { return }
            Task { @MainActor in
                self.syncRequestInFlight = false

                switch packet.status {
                case .batchStart:
                    self.syncBatchInProgress = true
                    Self.logger.debug("SYNC BATCH_START")

                case .batchEnd:
                    // Desktop parity (useSynchronize.ts): await whenFinish() then
                    // save server cursor and request next batch.
                    await self.waitForInboundQueueToDrain()
                    let serverCursor = packet.timestamp
                    self.saveLastSyncTimestamp(serverCursor)
                    // Re-apply cross-device reads that arrived during sync.
                    // PacketRead can arrive BEFORE sync messages — markIncomingAsRead
                    // updates 0 rows because the message isn't in DB yet.
                    self.reapplyPendingSyncReads()
                    self.reapplyPendingOpponentReads()
                    // PERF: Skip per-batch reconcile — final reconcile at .notNeeded is sufficient.
                    Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
                    self.requestSynchronize(cursor: serverCursor)

                case .notNeeded:
                    // Android parity: finishSyncCycle() — clear flag, retry, refresh.
                    self.syncBatchInProgress = false
                    // Re-apply cross-device reads one final time.
                    self.reapplyPendingSyncReads()
                    self.reapplyPendingOpponentReads()
                    // PERF: Single call — reconcileAllDialogs handles both delivery + unread.
                    DialogRepository.shared.reconcileAllDialogs()
                    self.retryWaitingOutgoingMessagesAfterReconnect()
                    Self.logger.debug("SYNC NOT_NEEDED")
                    // One-time recovery: re-sync from 0 if group keys are missing.
                    self.recoverOrphanedGroupsIfNeeded()
                    // Refresh user info now that sync is done.
                    Task { @MainActor [weak self] in
                        try? await Task.sleep(for: .milliseconds(300))
                        await self?.refreshOnlineStatusForAllDialogs()
                    }
                }
            }
        }

        proto.onRequestUpdateReceived = { packet in
            Self.logger.debug("RequestUpdate packet received: server=\(packet.updateServer)")
        }

        proto.onCreateGroupReceived = { packet in
            Self.logger.debug("CreateGroup packet received: groupId=\(packet.groupId)")
        }

        proto.onGroupInfoReceived = { packet in
            Self.logger.debug("GroupInfo packet received: groupId=\(packet.groupId), members=\(packet.members.count)")
        }

        proto.onGroupInviteInfoReceived = { packet in
            Self.logger.debug(
                "GroupInviteInfo packet received: groupId=\(packet.groupId), members=\(packet.membersCount), status=\(packet.status.rawValue)"
            )
        }

        proto.onGroupJoinReceived = { [weak self] packet in
            guard let self else { return }
            Task { @MainActor in
                guard let privateKeyHex = self.privateKeyHex else { return }
                guard !self.currentPublicKey.isEmpty else { return }

                guard let dialogKey = GroupRepository.shared.upsertFromGroupJoin(
                    account: self.currentPublicKey,
                    privateKeyHex: privateKeyHex,
                    packet: packet
                ) else {
                    return
                }

                let metadata = GroupRepository.shared.groupMetadata(
                    account: self.currentPublicKey,
                    groupDialogKey: dialogKey
                )

                DialogRepository.shared.ensureDialog(
                    opponentKey: dialogKey,
                    title: metadata?.title ?? "",
                    username: metadata?.description ?? "",
                    myPublicKey: self.currentPublicKey
                )

                // Re-decrypt messages that arrived before the group key was available.
                if let gk = GroupRepository.shared.groupKey(
                    account: self.currentPublicKey,
                    privateKeyHex: privateKeyHex,
                    groupDialogKey: dialogKey
                ) {
                    MessageRepository.shared.reDecryptGroupMessages(
                        account: self.currentPublicKey,
                        groupDialogKey: dialogKey,
                        groupKey: gk,
                        privateKeyHex: privateKeyHex
                    )
                }
                DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey)
            }
        }

        proto.onGroupLeaveReceived = { packet in
            Self.logger.debug("GroupLeave packet received: groupId=\(packet.groupId)")
        }

        proto.onGroupBanReceived = { packet in
            Self.logger.debug("GroupBan packet received: groupId=\(packet.groupId), publicKey=\(packet.publicKey)")
        }

        proto.onDeviceNewReceived = { [weak self] packet in
            Task { @MainActor in
                self?.handleDeviceNewLogin(packet)
            }
        }
    }

    private func handleDeviceNewLogin(_ packet: PacketDeviceNew) {
        let myKey = currentPublicKey
        guard !myKey.isEmpty else { return }

        // Desktop parity: dotCenterIfNeeded(deviceId, 12, 4)
        let truncId: String
        if packet.deviceId.count > 12 {
            truncId = "\(packet.deviceId.prefix(4))...\(packet.deviceId.suffix(4))"
        } else {
            truncId = packet.deviceId
        }

        // Desktop parity: useDeviceMessage.ts messageTemplate
        let text = """
        **Attempt to login from a new device**

        We detected a login to your account from **\(packet.ipAddress)** a new device **by seed phrase**. If this was you, you can safely ignore this message.

        **Arch:** \(packet.deviceOs)
        **IP:** \(packet.ipAddress)
        **Device:** \(packet.deviceName)
        **ID:** \(truncId)
        """

        let safeKey = SystemAccounts.safePublicKey
        let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
        let messageId = UUID().uuidString

        // Desktop parity: Safe account has verified: 1 (public figure/brand)
        DialogRepository.shared.ensureDialog(
            opponentKey: safeKey,
            title: SystemAccounts.safeTitle,
            username: "safe",
            verified: 1,
            myPublicKey: myKey
        )

        var fakePacket = PacketMessage()
        fakePacket.fromPublicKey = safeKey
        fakePacket.toPublicKey = myKey
        fakePacket.messageId = messageId
        fakePacket.timestamp = timestamp

        MessageRepository.shared.upsertFromMessagePacket(
            fakePacket, myPublicKey: myKey,
            decryptedText: text, fromSync: false
        )
        DialogRepository.shared.updateDialogFromMessages(opponentKey: safeKey)
    }

    /// Dedup set for the current processing queue — prevents the same messageId
    /// from being enqueued twice (real-time + sync overlap). Cleared after queue drains.
    private var enqueuedMessageIds: Set<String> = []

    private func enqueueIncomingMessage(_ packet: PacketMessage) {
        // Queue-level dedup: if the same messageId is already pending, skip.
        // This catches the common real-time + sync overlap case without
        // burning CPU on duplicate crypto + DB upsert.
        let msgId = packet.messageId
        if !msgId.isEmpty && enqueuedMessageIds.contains(msgId) { return }
        if !msgId.isEmpty { enqueuedMessageIds.insert(msgId) }

        pendingIncomingMessages.append(packet)
        guard !isProcessingIncomingMessages else { return }
        isProcessingIncomingMessages = true

        Task { @MainActor [weak self] in
            guard let self else { return }
            await self.processIncomingMessagesQueue()
        }
    }

    private func processIncomingMessagesQueue() async {
        // PERF: During sync, batch all message writes → single @Published event.
        // Real-time messages publish immediately for instant UI feedback.
        // Android parity: check thread-safe flag too (BATCH_START MainActor Task may lag).
        let batching = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive
        if batching { MessageRepository.shared.beginBatchUpdates() }

        while !pendingIncomingMessages.isEmpty {
            let batch = pendingIncomingMessages
            pendingIncomingMessages.removeAll(keepingCapacity: true)
            for packet in batch {
                await processIncomingMessage(packet)
                await Task.yield()
            }
        }

        if batching { MessageRepository.shared.endBatchUpdates() }
        isProcessingIncomingMessages = false
        enqueuedMessageIds.removeAll()
        signalQueueDrained()
    }

    private func processIncomingMessage(_ packet: PacketMessage) async {
        PerformanceLogger.shared.track("session.processIncoming")
        let myKey = currentPublicKey
        let currentPrivateKeyHex = self.privateKeyHex
        let currentPrivateKeyHash = self.privateKeyHash

        guard let context = Self.resolveMessagePacketContext(packet, ownKey: myKey) else {
            return
        }
        let fromMe = context.fromMe

        let opponentKey = context.dialogKey
        let isGroupDialog = context.kind == .group
        let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId)

        if packet.content.isEmpty && packet.attachments.isEmpty {
            Self.logger.warning("""
                processIncoming: drop empty payload packet \
                msgId=\(packet.messageId.prefix(8))… \
                from=\(packet.fromPublicKey.prefix(8))… \
                to=\(packet.toPublicKey.prefix(8))…
                """)
            scheduleMessageRecoveryResync(messageId: packet.messageId, fingerprint: "empty_payload")
            return
        }

        // Optimization: skip expensive crypto + upsert for incoming messages
        // already stored in DB. Only outgoing messages need re-processing
        // (sync may update delivery status from .waiting → .delivered).
        // This also eliminates any race-condition window between real-time
        // and sync delivery of the same messageId.
        if wasKnownBefore && !fromMe {
            return
        }

        let groupKey: String? = {
            guard isGroupDialog, let currentPrivateKeyHex else { return nil }
            return GroupRepository.shared.groupKey(
                account: myKey,
                privateKeyHex: currentPrivateKeyHex,
                groupDialogKey: opponentKey
            )
        }()
        if isGroupDialog, groupKey == nil {
            Self.logger.warning("processIncoming: group key not found for \(opponentKey) — dropping and scheduling resync")
            scheduleMessageRecoveryResync(messageId: packet.messageId, fingerprint: "group_key_missing")
            return
        }

        // ── PERF: Offload all crypto to background thread ──
        // decryptIncomingMessage (ECDH + XChaCha20) and attachment blob
        // decryption (PBKDF2 × N candidates) are CPU-heavy pure computation.
        // Running them on MainActor blocked the UI for seconds during sync.
        let cryptoResult = await Task.detached(priority: .userInitiated) {
            Self.decryptAndProcessAttachments(
                packet: packet,
                myPublicKey: myKey,
                privateKeyHex: currentPrivateKeyHex,
                groupKey: groupKey
            )
        }.value

        guard let cryptoResult else {
            Self.logger.error("""
                processIncoming: decrypt FAILED — dropping packet \
                msgId=\(packet.messageId.prefix(8))… \
                from=\(packet.fromPublicKey.prefix(8))… \
                hasChachaKey=\(!packet.chachaKey.isEmpty) \
                hasAesChachaKey=\(!packet.aesChachaKey.isEmpty) \
                contentLen=\(packet.content.count) \
                isOwnMessage=\(fromMe)
                """)
            scheduleMessageRecoveryResync(messageId: packet.messageId, fingerprint: "decrypt_failed")
            return
        }
        let text = cryptoResult.text
        let processedPacket = cryptoResult.processedPacket
        let resolvedAttachmentPassword = cryptoResult.attachmentPassword

        if text.isEmpty && processedPacket.attachments.isEmpty {
            Self.logger.warning("""
                processIncoming: drop post-decrypt empty payload \
                msgId=\(packet.messageId.prefix(8))… \
                from=\(packet.fromPublicKey.prefix(8))… \
                to=\(packet.toPublicKey.prefix(8))…
                """)
            scheduleMessageRecoveryResync(
                messageId: packet.messageId,
                fingerprint: "empty_decrypted_payload"
            )
            return
        }

        // For outgoing messages received from the server (sent by another device
        // on the same account), treat as sync-equivalent so status = .delivered.
        // Without this, real-time fromMe messages get .waiting → timeout → .error.
        // Android parity: also check isSyncBatchActive (set synchronously on receive
        // queue) to handle race where BATCH_START MainActor Task hasn't run yet.
        let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive || fromMe

        // Android parity: insert message to DB FIRST, then update dialog.
        // Dialog's unreadCount uses COUNT(*) WHERE read=0, so the message
        // must exist in DB before the count query runs.
        MessageRepository.shared.upsertFromMessagePacket(
            processedPacket,
            myPublicKey: myKey,
            decryptedText: text,
            attachmentPassword: resolvedAttachmentPassword,
            fromSync: effectiveFromSync,
            dialogIdentityOverride: opponentKey
        )
        // PERF: During sync, SKIP per-message dialog recalculation.
        // updateDialogFromMessages() does 4 SQL queries + decrypt per call.
        // With 200 messages = 800 queries + 200 decrypts on MainActor = 10-20s blocking.
        // Telegram batches all updates into one transaction at sync end.
        // reconcileAllDialogs() at .notNeeded does the identical work in batch mode
        // (single `dialogs = batch` assignment → 1 observation trigger, not 200).
        if effectiveFromSync {
            // Still ensure dialog exists so it appears in chat list during sync.
            // PERF: skipPersist=true — DB write deferred to reconcileAllDialogs() at BATCH_END.
            // Saves N individual writeSync transactions during sync (server batch size = 50).
            if !isGroupDialog {
                let existing = DialogRepository.shared.dialogs[opponentKey]
                DialogRepository.shared.ensureDialog(
                    opponentKey: opponentKey,
                    title: existing?.opponentTitle ?? "",
                    username: existing?.opponentUsername ?? "",
                    myPublicKey: myKey,
                    skipPersist: true
                )
            }
        } else {
            // Real-time message (not sync) — update dialog immediately for instant UI.
            DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
        }
        // Track dirty key so end-of-sync reconcile only hits changed dialogs.
        DialogRepository.shared.markDirtyForReconcile(opponentKey)
        if isGroupDialog,
           let metadata = GroupRepository.shared.groupMetadata(account: myKey, groupDialogKey: opponentKey) {
            DialogRepository.shared.ensureDialog(
                opponentKey: opponentKey,
                title: metadata.title,
                username: metadata.description,
                myPublicKey: myKey,
                skipPersist: effectiveFromSync
            )
        }

        // Desktop parity: detect @mentions in group messages.
        // Set hasMention on the dialog when the text mentions the current user.
        if isGroupDialog && !fromMe && !text.isEmpty {
            let lowered = text.lowercased()
            let myUsername = AccountManager.shared.currentAccount?.username?.lowercased() ?? ""
            let mentioned = (!myUsername.isEmpty && lowered.contains("@\(myUsername)"))
                || lowered.contains("@\(myKey.prefix(8))")
                || lowered.contains("@all")
            if mentioned {
                DialogRepository.shared.setMention(opponentKey: opponentKey, hasMention: true)
            }
        }

        // Desktop parity: if we received a message from the opponent (not our own),
        // they are clearly online — update their online status immediately.
        // This supplements PacketOnlineState (0x05) which may arrive with delay.
        if !fromMe && !effectiveFromSync && !isGroupDialog {
            DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true)
        }

        let dialog = DialogRepository.shared.dialogs[opponentKey]

        if !isGroupDialog, dialog?.opponentTitle.isEmpty == true {
            requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash)
        }

        // Desktop parity: do NOT send PacketDelivery (0x08) back to server.
        // The server auto-generates delivery confirmations when it forwards
        // the message — the client never needs to acknowledge receipt explicitly.
        // Sending 0x08 for every received message was causing a packet flood
        // that triggered server RST disconnects.

        // Telegram parity: show in-app banner for foreground messages in non-active chats.
        if !fromMe && !effectiveFromSync && isAppInForeground {
            // PERF: use in-memory dialog state instead of UserDefaults I/O per message.
            let isMuted = dialog?.isMuted == true
            // Desktop-active suppression: skip banner if dialog was read on another device < 30s ago.
            let isDesktopActive: Bool = {
                if let readDate = self.desktopActiveDialogs[opponentKey] {
                    return Date().timeIntervalSince(readDate) < 30
                }
                return false
            }()
            if !MessageRepository.shared.isDialogActive(opponentKey) && !isMuted && !isDesktopActive {
                let senderName = dialog?.opponentTitle ?? ""
                let preview: String = {
                    if !text.isEmpty {
                        return EmojiParser.replaceShortcodes(in: text)
                    }
                    if let firstAtt = processedPacket.attachments.first {
                        switch firstAtt.type {
                        case .image: return "Photo"
                        case .file: return "File"
                        case .voice: return "Voice message"
                        case .avatar: return "Avatar"
                        case .messages: return "Forwarded message"
                        case .call: return "Call"
                        @unknown default: return "Attachment"
                        }
                    }
                    return "New message"
                }()
                let bannerData = InAppBannerManager.BannerData(
                    senderKey: opponentKey,
                    senderName: senderName.isEmpty ? String(opponentKey.prefix(8)) : senderName,
                    messagePreview: preview,
                    isGroup: isGroupDialog,
                    verified: dialog?.verified ?? 0
                )
                NotificationCenter.default.post(
                    name: InAppBannerManager.showBannerNotification,
                    object: bannerData
                )
            }
        }

        // Desktop/Android parity: mark as read if dialog is active, read-eligible,
        // app is in foreground, AND user is not idle (20s timeout).
        let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
        let dialogIsReadEligible = MessageRepository.shared.isDialogReadEligible(opponentKey)
        let isSystem = SystemAccounts.isSystemAccount(opponentKey)
        let fg = isAppInForeground
        let idle = isUserIdle
        let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem && !idle

        if shouldMarkRead {
            MessageRepository.shared.markIncomingAsRead(
                opponentKey: opponentKey,
                myPublicKey: myKey
            )
            DialogRepository.shared.markAsRead(opponentKey: opponentKey)
            if !fromMe && !wasKnownBefore {
                // Android/Desktop parity: send read receipt immediately,
                // even during sync. 400ms debounce prevents flooding.
                sendReadReceipt(toPublicKey: opponentKey)
            }
        }

        // Desktop parity (useUpdateSyncTime.ts): no-op during SYNCHRONIZATION.
        // Sync cursor is updated once at BATCH_END with the server's timestamp.
        // Android parity: check both MainActor flag and thread-safe flag for race safety.
        if !effectiveFromSync {
            saveLastSyncTimestamp(packet.timestamp)
        }
    }

    /// Continuations waiting for the inbound queue to drain.
    private var drainContinuations: [CheckedContinuation<Void, Never>] = []

    /// Signal all waiting continuations that the queue has drained.
    private func signalQueueDrained() {
        let waiting = drainContinuations
        drainContinuations.removeAll()
        for continuation in waiting {
            continuation.resume()
        }
    }

    /// Desktop parity (dialogQueue.ts `whenFinish`): waits indefinitely for all
    /// enqueued message tasks to complete before advancing the sync cursor.
    private func waitForInboundQueueToDrain() async {
        // Fast path: already drained
        if !isProcessingIncomingMessages && pendingIncomingMessages.isEmpty {
            return
        }

        // Event-based: wait for signalQueueDrained()
        await withCheckedContinuation { continuation in
            self.drainContinuations.append(continuation)
        }
    }

    // Sync cursor key (legacy UserDefaults, kept for migration)
    private var syncCursorKey: String {
        "rosetta_last_sync_\(currentPublicKey)"
    }

    // MARK: - Online Status Subscription

    /// Subscribe to a single user's online status (called when opening a chat, like Android).
    /// Only sends when authenticated — does NOT queue to avoid flooding server on reconnect.
    func subscribeToOnlineStatus(publicKey: String) {
        guard !publicKey.isEmpty,
              ProtocolManager.shared.connectionState == .authenticated,
              !onlineSubscribedKeys.contains(publicKey),
              let hash = privateKeyHash
        else { return }
        onlineSubscribedKeys.insert(publicKey)
        var packet = PacketOnlineSubscribe()
        packet.privateKey = hash
        packet.publicKeys = [publicKey]
        ProtocolManager.shared.sendPacket(packet)
    }

    /// Force-refresh user info (including online status) by sending PacketSearch
    /// without dedup check. Desktop parity: `forceUpdateUserInformation()`.
    func forceRefreshUserInfo(publicKey: String) {
        guard !publicKey.isEmpty,
              let hash = privateKeyHash,
              ProtocolManager.shared.connectionState == .authenticated
        else { return }
        var searchPacket = PacketSearch()
        searchPacket.privateKey = hash
        searchPacket.search = publicKey
        ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
    }

    private func handleMalformedMessagePacket(_ info: MalformedMessagePacketInfo) {
        let fingerprint = info.fingerprint.isEmpty ? "packet06_parse_failed" : info.fingerprint
        Self.logger.error("""
            Dropping malformed 0x06 packet \
            size=\(info.packetSize) \
            msgHint=\(info.messageIdHint) \
            fp=\(fingerprint)
            """)

        malformedMessageResyncQueued = true
        malformedMessageResyncTask?.cancel()
        scheduleMalformedMessageResyncFlush(afterNanoseconds: Self.malformedMessageResyncDebounceNs)
    }

    private func scheduleMessageRecoveryResync(messageId: String, fingerprint: String) {
        let msgHint = messageId.isEmpty ? "-" : String(messageId.prefix(8))
        handleMalformedMessagePacket(
            MalformedMessagePacketInfo(
                packetSize: 0,
                fingerprint: fingerprint,
                messageIdHint: msgHint
            )
        )
    }

    private func scheduleMalformedMessageResyncFlush(afterNanoseconds delay: UInt64) {
        malformedMessageResyncTask = Task { @MainActor [weak self] in
            try? await Task.sleep(nanoseconds: delay)
            guard let self, !Task.isCancelled else { return }
            self.malformedMessageResyncTask = nil
            self.flushMalformedMessageResync()
        }
    }

    private func flushMalformedMessageResync() {
        guard malformedMessageResyncQueued else { return }

        guard !currentPublicKey.isEmpty else {
            malformedMessageResyncQueued = false
            return
        }

        if syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive {
            scheduleMalformedMessageResyncFlush(afterNanoseconds: Self.malformedMessageResyncRetryDelayNs)
            return
        }

        if syncRequestInFlight {
            scheduleMalformedMessageResyncFlush(afterNanoseconds: Self.malformedMessageResyncRetryDelayNs)
            return
        }

        malformedMessageResyncQueued = false
        let cursor = loadLastSyncTimestamp()

        #if DEBUG
        malformedMessageResyncTriggerCount += 1
        if let hook = malformedMessageResyncTestHook {
            hook()
            return
        }
        #endif

        Self.logger.warning("Malformed 0x06 recovery: requesting sync cursor=\(cursor)")
        requestSynchronize(cursor: cursor)
    }

    private func requestSynchronize(cursor: Int64? = nil) {
        // No connectionState guard: this method is only called from (1) handshake
        // completion handler and (2) BATCH_END handler — both inherently authenticated.
        // The old `connectionState == .authenticated` guard caused a race condition:
        // ProtocolManager sets .authenticated in a separate MainActor task, so if
        // requestSynchronize() ran first, the guard silently dropped the sync request.
        guard !syncRequestInFlight else { return }

        syncRequestInFlight = true
        // Server and all platforms use MILLISECONDS for sync cursors.
        // Pass cursor as-is — no normalization needed.
        let lastSync = cursor ?? loadLastSyncTimestamp()

        var packet = PacketSync()
        packet.status = .notNeeded
        packet.timestamp = lastSync

        Self.logger.debug("Requesting sync with timestamp \(lastSync)")
        ProtocolManager.shared.sendPacket(packet)
    }

    /// Android parity: sync cursor stored in SQLite (not UserDefaults).
    /// Migrates from UserDefaults on first call.
    private func loadLastSyncTimestamp() -> Int64 {
        guard !currentPublicKey.isEmpty else { return 0 }

        // Try SQLite first
        let sqliteValue = DatabaseManager.shared.loadSyncCursor(account: currentPublicKey)
        if sqliteValue > 0 { return sqliteValue }

        // Migrate from UserDefaults (one-time)
        let legacyValue = Int64(UserDefaults.standard.integer(forKey: syncCursorKey))
        if legacyValue > 0 {
            let normalized = legacyValue < 1_000_000_000_000 ? legacyValue * 1000 : legacyValue
            DatabaseManager.shared.saveSyncCursor(account: currentPublicKey, timestamp: normalized)
            UserDefaults.standard.removeObject(forKey: syncCursorKey)
            return normalized
        }
        return 0
    }

    /// Android parity: sync cursor stored in SQLite. Monotonic only.
    private func saveLastSyncTimestamp(_ raw: Int64) {
        guard !currentPublicKey.isEmpty, raw > 0 else { return }
        DatabaseManager.shared.saveSyncCursor(account: currentPublicKey, timestamp: raw)
    }

    // MARK: - Orphaned Group Recovery

    /// Detects group dialogs without a key in the `groups` table and triggers a full
    /// re-sync (cursor=0) to recover the missing `PacketGroupJoin` from the server buffer.
    /// Runs once per session to avoid infinite sync loops.
    private func recoverOrphanedGroupsIfNeeded() {
        guard !hasTriggeredGroupRecoverySync else { return }
        guard let privKey = privateKeyHex, !currentPublicKey.isEmpty else { return }

        let groupDialogKeys = DialogRepository.shared.dialogs.keys.filter {
            DatabaseManager.isGroupDialogKey($0)
        }
        guard !groupDialogKeys.isEmpty else { return }

        var orphaned = false
        for key in groupDialogKeys {
            if GroupRepository.shared.groupKey(
                account: currentPublicKey,
                privateKeyHex: privKey,
                groupDialogKey: key
            ) == nil {
                Self.logger.warning("Orphaned group detected (no key): \(key)")
                orphaned = true
                break
            }
        }

        guard orphaned else { return }

        hasTriggeredGroupRecoverySync = true
        Self.logger.info("Resetting sync cursor to 0 for orphaned group key recovery")
        DatabaseManager.shared.resetSyncCursor(account: currentPublicKey)
        syncRequestInFlight = false
        requestSynchronize()
    }

    // MARK: - Background Crypto (off MainActor)

    /// Result of background crypto operations for an incoming message.
    private struct IncomingCryptoResult: Sendable {
        let text: String
        let processedPacket: PacketMessage
        let attachmentPassword: String?
    }

    /// Decrypts message text + attachment blobs on a background thread.
    /// Pure computation — no UI or repository access.
    nonisolated private static func decryptAndProcessAttachments(
        packet: PacketMessage,
        myPublicKey: String,
        privateKeyHex: String?,
        groupKey: String?
    ) -> IncomingCryptoResult? {
        guard let result = decryptIncomingMessage(
            packet: packet,
            myPublicKey: myPublicKey,
            privateKeyHex: privateKeyHex,
            groupKey: groupKey
        ) else { return nil }

        var processedPacket = packet
        var resolvedAttachmentPassword: String?

        if let keyData = result.rawKeyData {
            resolvedAttachmentPassword = "rawkey:" + keyData.hexString
            let passwordCandidates = MessageCrypto.attachmentPasswordCandidates(
                from: resolvedAttachmentPassword!
            )

            // PERF: Remember which password candidate succeeded for the first attachment,
            // then try it first for subsequent attachments. All candidates are still tried
            // as fallback — only the ORDER changes. Avoids redundant PBKDF2 derivations.
            var preferredPassword: String?

            for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages {
                let blob = processedPacket.attachments[i].blob
                guard !blob.isEmpty else { continue }
                var decrypted = false

                let ordered = Self.orderedCandidates(passwordCandidates, preferred: preferredPassword)
                for password in ordered {
                    if let data = try? CryptoManager.shared.decryptWithPassword(
                        blob, password: password, requireCompression: true
                    ),
                       let decryptedString = String(data: data, encoding: .utf8) {
                        processedPacket.attachments[i].blob = decryptedString
                        decrypted = true
                        if preferredPassword == nil { preferredPassword = password }
                        break
                    }
                }
                if !decrypted {
                    for password in ordered {
                        if let data = try? CryptoManager.shared.decryptWithPassword(
                            blob, password: password
                        ),
                           let decryptedString = String(data: data, encoding: .utf8) {
                            processedPacket.attachments[i].blob = decryptedString
                            if preferredPassword == nil { preferredPassword = password }
                            break
                        }
                    }
                }
            }
        } else if let groupKey {
            // Android parity: plain groupKey as PBKDF2 password.
            // Try plain first (Android + new iOS), hex second (old iOS + Desktop sends).
            resolvedAttachmentPassword = groupKey
            for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages {
                let blob = processedPacket.attachments[i].blob
                guard !blob.isEmpty else { continue }
                if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey),
                   let decryptedString = String(data: data, encoding: .utf8) {
                    processedPacket.attachments[i].blob = decryptedString
                } else {
                    // Backward compat: old iOS + Desktop send with hex(groupKey)
                    let hexGroupKey = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined()
                    if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: hexGroupKey),
                       let decryptedString = String(data: data, encoding: .utf8) {
                        processedPacket.attachments[i].blob = decryptedString
                    }
                }
            }
        }

        return IncomingCryptoResult(
            text: result.text,
            processedPacket: processedPacket,
            attachmentPassword: resolvedAttachmentPassword
        )
    }

    /// Reorder password candidates so `preferred` is tried first.
    /// All candidates are still present — only the order changes.
    nonisolated private static func orderedCandidates(
        _ candidates: [String], preferred: String?
    ) -> [String] {
        guard let preferred, let idx = candidates.firstIndex(of: preferred) else {
            return candidates
        }
        var reordered = candidates
        reordered.remove(at: idx)
        reordered.insert(preferred, at: 0)
        return reordered
    }

    /// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption.
    nonisolated private static func decryptIncomingMessage(
        packet: PacketMessage,
        myPublicKey: String,
        privateKeyHex: String?,
        groupKey: String?
    ) -> (text: String, rawKeyData: Data?)? {
        let isOwnMessage = packet.fromPublicKey == myPublicKey

        if let groupKey {
            if packet.content.isEmpty {
                return ("", nil)
            }
            if let data = try? CryptoManager.shared.decryptWithPassword(
                packet.content,
                password: groupKey
            ), let text = String(data: data, encoding: .utf8) {
                return (text, nil)
            }
            Self.logger.warning("Group decrypt failed for msgId=\(packet.messageId.prefix(8))…")
            return nil
        }

        guard let privateKeyHex else {
            return nil
        }
        // Allow empty content only for attachment-only packets.
        // Text messages with empty content and no attachments are treated as invalid
        // and must not create empty bubbles in UI.
        if packet.content.isEmpty {
            guard !packet.attachments.isEmpty else {
                Self.logger.warning("Rejecting packet with empty content and no attachments")
                return nil
            }
            return ("", nil)
        }

        // Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce).
        if isOwnMessage, !packet.aesChachaKey.isEmpty {
            do {
                let decryptedPayload = try CryptoManager.shared.decryptWithPassword(
                    packet.aesChachaKey,
                    password: privateKeyHex
                )
                // decryptedPayload = UTF-8 bytes of Latin-1 string.
                // androidUtf8BytesToLatin1Bytes recovers the raw 56-byte key+nonce.
                let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload)
                let text = try MessageCrypto.decryptIncomingWithPlainKey(
                    ciphertext: packet.content,
                    plainKeyAndNonce: keyAndNonce
                )
                // Return raw 56 bytes (not the UTF-8 payload) so
                // attachmentPasswordCandidates can correctly derive WHATWG/Android passwords.
                return (text, keyAndNonce)
            } catch {
                Self.logger.debug("AES-CHACHA sync path failed, falling through to ECDH…")
            }
        }

        // ECDH path (works for opponent messages, may work for own if chachaKey targets us)
        guard !packet.chachaKey.isEmpty else {
            return nil
        }

        do {
            let (text, keyAndNonce) = try MessageCrypto.decryptIncomingFull(
                ciphertext: packet.content,
                encryptedKey: packet.chachaKey,
                myPrivateKeyHex: privateKeyHex
            )
            return (text, keyAndNonce)
        } catch {
            Self.logger.warning("ECDH decrypt failed for msgId=\(packet.messageId.prefix(8))…: \(error)")
            return nil
        }
    }

    private enum PacketDialogKind {
        case direct
        case saved
        case group
    }

    private struct PacketDialogContext {
        let kind: PacketDialogKind
        let dialogKey: String
        let fromKey: String
        let toKey: String
        let fromMe: Bool
        let toMe: Bool
    }

    private static func normalizedGroupDialogIdentity(_ value: String) -> String {
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        let lower = trimmed.lowercased()
        if lower.hasPrefix("group:") {
            let id = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
            return id.isEmpty ? trimmed : "#group:\(id)"
        }
        return trimmed
    }

    private static func isUnsupportedDialogKey(_ value: String) -> Bool {
        let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        if normalized.isEmpty { return true }
        if DatabaseManager.isGroupDialogKey(normalized) { return false }
        return normalized.hasPrefix("#")
    }

    private static func isSupportedDirectPeerKey(_ peerKey: String, ownKey: String) -> Bool {
        let normalized = peerKey.trimmingCharacters(in: .whitespacesAndNewlines)
        if normalized.isEmpty { return false }
        if normalized == ownKey { return true }
        if SystemAccounts.isSystemAccount(normalized) { return true }
        return !isUnsupportedDialogKey(normalized)
    }

    private static func resolveDialogContext(from: String, to: String, ownKey: String) -> PacketDialogContext? {
        if ownKey.isEmpty { return nil }
        let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines)
        let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines)
        if fromKey.isEmpty || toKey.isEmpty { return nil }

        if DatabaseManager.isGroupDialogKey(toKey) {
            return PacketDialogContext(
                kind: .group,
                dialogKey: normalizedGroupDialogIdentity(toKey),
                fromKey: fromKey,
                toKey: toKey,
                fromMe: fromKey == ownKey,
                toMe: toKey == ownKey
            )
        }

        if fromKey == ownKey {
            guard isSupportedDirectPeerKey(toKey, ownKey: ownKey) else { return nil }
            return PacketDialogContext(
                kind: toKey == ownKey ? .saved : .direct,
                dialogKey: toKey,
                fromKey: fromKey,
                toKey: toKey,
                fromMe: true,
                toMe: toKey == ownKey
            )
        }
        if toKey == ownKey {
            guard isSupportedDirectPeerKey(fromKey, ownKey: ownKey) else { return nil }
            return PacketDialogContext(
                kind: fromKey == ownKey ? .saved : .direct,
                dialogKey: fromKey,
                fromKey: fromKey,
                toKey: toKey,
                fromMe: false,
                toMe: true
            )
        }
        return nil
    }

    private static func resolveMessagePacketContext(_ packet: PacketMessage, ownKey: String) -> PacketDialogContext? {
        resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey)
    }

    /// Clears delivered push notifications for a specific dialog and decrements badge.
    /// Mirrors handleReadPush() logic from RosettaApp.swift — used by WebSocket read path.
    private static func clearDeliveredNotifications(for dialogKey: String) {
        let center = UNUserNotificationCenter.current()
        center.getDeliveredNotifications { delivered in
            let idsToRemove = delivered
                .filter { $0.request.content.userInfo["sender_public_key"] as? String == dialogKey }
                .map { $0.request.identifier }
            guard !idsToRemove.isEmpty else { return }
            center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
            let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
            let current = shared?.integer(forKey: "app_badge_count") ?? 0
            let newBadge = max(current - idsToRemove.count, 0)
            shared?.set(newBadge, forKey: "app_badge_count")
            UNUserNotificationCenter.current().setBadgeCount(newBadge)
        }
    }

    private static func resolveReadPacketContext(_ packet: PacketRead, ownKey: String) -> PacketDialogContext? {
        resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey)
    }

    private static func resolveTypingPacketContext(_ packet: PacketTyping, ownKey: String) -> PacketDialogContext? {
        resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey)
    }

    // MARK: - Test Support

    static func testResolveMessagePacketContext(
        _ packet: PacketMessage,
        ownKey: String
    ) -> (kind: String, dialogKey: String, fromMe: Bool)? {
        guard let context = resolveMessagePacketContext(packet, ownKey: ownKey) else { return nil }
        let kind: String
        switch context.kind {
        case .direct: kind = "direct"
        case .saved: kind = "saved"
        case .group: kind = "group"
        }
        return (kind, context.dialogKey, context.fromMe)
    }

    static func testResolveReadPacketContext(
        _ packet: PacketRead,
        ownKey: String
    ) -> (kind: String, dialogKey: String, fromMe: Bool)? {
        guard let context = resolveReadPacketContext(packet, ownKey: ownKey) else { return nil }
        let kind: String
        switch context.kind {
        case .direct: kind = "direct"
        case .saved: kind = "saved"
        case .group: kind = "group"
        }
        return (kind, context.dialogKey, context.fromMe)
    }

    static func testResolveTypingPacketContext(
        _ packet: PacketTyping,
        ownKey: String
    ) -> (kind: String, dialogKey: String, fromMe: Bool)? {
        guard let context = resolveTypingPacketContext(packet, ownKey: ownKey) else { return nil }
        let kind: String
        switch context.kind {
        case .direct: kind = "direct"
        case .saved: kind = "saved"
        case .group: kind = "group"
        }
        return (kind, context.dialogKey, context.fromMe)
    }

    func testConfigureSessionForParityFlows(
        currentPublicKey: String,
        privateKeyHex: String,
        privateKeyHash: String? = nil
    ) {
        self.currentPublicKey = currentPublicKey
        self.privateKeyHex = privateKeyHex
        self.privateKeyHash = privateKeyHash ?? CryptoManager.shared.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
    }

    func testResetParityFlowDependencies() {
        attachmentFlowTransport = LiveAttachmentFlowTransport()
        packetFlowSender = LivePacketFlowSender()
    }

    static func testDecryptIncomingMessage(
        packet: PacketMessage,
        myPublicKey: String,
        privateKeyHex: String?,
        groupKey: String?
    ) -> (text: String, hasRawKeyData: Bool)? {
        guard let result = decryptIncomingMessage(
            packet: packet,
            myPublicKey: myPublicKey,
            privateKeyHex: privateKeyHex,
            groupKey: groupKey
        ) else {
            return nil
        }
        return (result.text, result.rawKeyData != nil)
    }

    #if DEBUG
    static func testRecoverRetryPlaintext(storedText: String, privateKeyHex: String) -> String? {
        recoverRetryPlaintext(storedText: storedText, privateKeyHex: privateKeyHex)
    }

    static func testRawKeyAndNonceFromStoredAttachmentPassword(_ stored: String) -> Data? {
        rawKeyAndNonceFromStoredAttachmentPassword(stored)
    }

    func testSetMalformedMessageResyncHook(_ hook: (() -> Void)?) {
        malformedMessageResyncTestHook = hook
    }

    func testSimulateMalformedMessagePacketDrop(
        packetSize: Int = 0,
        fingerprint: String = "test_packet06_malformed",
        messageIdHint: String = "-"
    ) {
        handleMalformedMessagePacket(
            MalformedMessagePacketInfo(
                packetSize: packetSize,
                fingerprint: fingerprint,
                messageIdHint: messageIdHint
            )
        )
    }

    func testResetMalformedMessageResyncState() {
        malformedMessageResyncTask?.cancel()
        malformedMessageResyncTask = nil
        malformedMessageResyncQueued = false
        malformedMessageResyncTestHook = nil
        malformedMessageResyncTriggerCount = 0
    }

    func testSetSyncState(
        syncRequestInFlight: Bool? = nil,
        syncBatchInProgress: Bool? = nil
    ) {
        if let syncRequestInFlight {
            self.syncRequestInFlight = syncRequestInFlight
        }
        if let syncBatchInProgress {
            self.syncBatchInProgress = syncBatchInProgress
        }
    }

    func testProcessIncomingMessage(_ packet: PacketMessage) async {
        await processIncomingMessage(packet)
    }
    #endif

    /// Public convenience for views that need to trigger a user-info fetch.
    func requestUserInfoIfNeeded(forKey publicKey: String) {
        requestUserInfoIfNeeded(opponentKey: publicKey, privateKeyHash: privateKeyHash)
    }

    private func requestUserInfoIfNeeded(opponentKey: String, privateKeyHash: String?) {
        guard let privateKeyHash else { return }
        let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalized.isEmpty else { return }
        guard !DatabaseManager.isGroupDialogKey(normalized) else { return }
        guard !requestedUserInfoKeys.contains(normalized) else { return }

        requestedUserInfoKeys.insert(normalized)

        var searchPacket = PacketSearch()
        searchPacket.privateKey = privateKeyHash
        searchPacket.search = normalized
        ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
    }

    /// Request user info for all existing dialog opponents after sync completes.
    /// Desktop parity: useUserInformation sends PacketSearch(publicKey) lazily per-component.
    /// We do it in bulk after sync — with generous staggering to avoid server rate-limiting.
    /// PERF: cap at 30 dialogs to avoid sending 100+ PacketSearch requests after sync.
    /// Each response triggers updateUserInfo → schedulePersist → JSON encoding cascade.
    /// Missing names are prioritized; online status refresh is limited.
    private func refreshOnlineStatusForAllDialogs() async {
        let dialogs = DialogRepository.shared.dialogs
        let ownKey = currentPublicKey

        // Split into priority (missing name) and normal (has name, refresh online)
        var missingName: [String] = []
        var hasName: [String] = []
        for (key, dialog) in dialogs {
            guard key != ownKey, !key.isEmpty else { continue }
            guard !DatabaseManager.isGroupDialogKey(key) else { continue }
            if dialog.opponentTitle.isEmpty {
                missingName.append(key)
            } else {
                hasName.append(key)
            }
        }

        // Priority: fetch missing names first (generous 250ms stagger).
        // PERF: increased stagger from 200→250ms to reduce CPU/network overhead
        // that contributes to phone overheating with many chats.
        var count = 0
        for key in missingName {
            guard ProtocolManager.shared.connectionState == .authenticated else { break }
            guard count < 30 else { break } // PERF: cap to prevent request storm
            requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
            count += 1
            if count > 1 {
                try? await Task.sleep(for: .milliseconds(250))
            }
        }

        // Then refresh online status for recently active dialogs only (400ms stagger).
        // Sort by lastMessageTimestamp descending — most recent chats first.
        let recentKeys = hasName
            .compactMap { key -> (String, Int64)? in
                guard let dialog = dialogs[key] else { return nil }
                return (key, dialog.lastMessageTimestamp)
            }
            .sorted { $0.1 > $1.1 }
            .prefix(max(0, 30 - count))
            .map(\.0)

        for key in recentKeys {
            guard ProtocolManager.shared.connectionState == .authenticated else { break }
            requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
            count += 1
            if count > 1 {
                try? await Task.sleep(for: .milliseconds(400))
            }
        }
        Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(recentKeys.count) online status = \(count) total (capped at 30)")
    }

    /// Persistent handler for ALL search results — updates dialog names/usernames from server data.
    /// This runs independently of ChatListViewModel's search UI handler.
    /// Also detects own profile in search results and updates SessionManager + AccountManager.
    private func setupUserInfoSearchHandler() {
        userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler(channel: .userInfo) { [weak self] packet in
            Task { @MainActor [weak self] in
                guard let self else { return }
                let ownKey = self.currentPublicKey
                for user in packet.users {
                    guard !user.publicKey.isEmpty else { continue }
                    Self.logger.debug("🔍 Search result: \(user.publicKey.prefix(12))… title='\(user.title)' online=\(user.online) verified=\(user.verified)")

                    // Own profile from server — update local profile if we have empty data
                    if user.publicKey == ownKey {
                        var updated = false
                        if !user.title.isEmpty && self.displayName.isEmpty {
                            self.displayName = user.title
                            AccountManager.shared.updateProfile(displayName: user.title, username: nil)
                            Self.logger.info("Own profile restored from server: title='\(user.title)'")
                            updated = true
                        }
                        if !user.username.isEmpty && self.username.isEmpty {
                            self.username = user.username
                            AccountManager.shared.updateProfile(displayName: nil, username: user.username)
                            Self.logger.info("Own profile restored from server: username='\(user.username)'")
                            updated = true
                        }
                        if self.verified != user.verified {
                            self.verified = user.verified
                            updated = true
                        }
                        if updated {
                            NotificationCenter.default.post(name: .profileDidUpdate, object: nil)
                        }
                    }

                    // Update user info + online status from search results
                    DialogRepository.shared.updateUserInfo(
                        publicKey: user.publicKey,
                        title: user.title,
                        username: user.username,
                        verified: user.verified,
                        online: user.online
                    )
                }
            }
        }
    }

    private func makeOutgoingPacket(
        text: String,
        toPublicKey: String,
        messageId: String,
        timestamp: Int64,
        privateKeyHex: String,
        privateKeyHash: String
    ) throws -> PacketMessage {
        let normalizedTarget = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
        if DatabaseManager.isGroupDialogKey(normalizedTarget) {
            let normalizedGroupTarget = Self.normalizedGroupDialogIdentity(normalizedTarget)
            guard let groupKey = GroupRepository.shared.groupKey(
                account: currentPublicKey,
                privateKeyHex: privateKeyHex,
                groupDialogKey: normalizedGroupTarget
            ) else {
                throw CryptoError.invalidData("Missing group key for \(normalizedGroupTarget)")
            }

            let encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                Data(text.utf8),
                password: groupKey
            )

            var packet = PacketMessage()
            packet.fromPublicKey = currentPublicKey
            packet.toPublicKey = normalizedGroupTarget
            packet.content = encryptedContent
            packet.chachaKey = ""
            packet.timestamp = timestamp
            packet.privateKey = privateKeyHash
            packet.messageId = messageId
            packet.aesChachaKey = ""
            return packet
        }

        let encrypted = try MessageCrypto.encryptOutgoing(
            plaintext: text,
            recipientPublicKeyHex: normalizedTarget
        )

        guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
            throw CryptoError.encryptionFailed
        }
        let aesChachaPayload = Data(latin1String.utf8)
        let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
            aesChachaPayload,
            password: privateKeyHex
        )

        var packet = PacketMessage()
        packet.fromPublicKey = currentPublicKey
        packet.toPublicKey = normalizedTarget
        packet.content = encrypted.content
        packet.chachaKey = encrypted.chachaKey
        packet.timestamp = timestamp
        packet.privateKey = privateKeyHash
        packet.messageId = messageId
        packet.aesChachaKey = aesChachaKey
        return packet
    }

    private func retryWaitingOutgoingMessagesAfterReconnect() {
        guard !currentPublicKey.isEmpty,
              let privateKeyHex,
              let privateKeyHash
        else { return }

        let now = Int64(Date().timeIntervalSince1970 * 1000)
        // Android parity: MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000.
        let maxRetryAgeMs: Int64 = 80_000

        // Android parity: batch-mark expired WAITING messages as ERROR first (SQL UPDATE).
        let expiredCount = MessageRepository.shared.markExpiredWaitingAsError(
            myPublicKey: currentPublicKey,
            maxTimestamp: now - maxRetryAgeMs
        )
        if expiredCount > 0 {
            Self.logger.warning("Marked \(expiredCount) expired WAITING messages as ERROR")
        }

        // Android parity: get remaining WAITING messages within the window.
        let retryable = MessageRepository.shared.resolveRetryableOutgoingMessages(
            myPublicKey: currentPublicKey,
            nowMs: now,
            maxRetryAgeMs: maxRetryAgeMs
        )

        for message in retryable {
            if message.toPublicKey == currentPublicKey {
                continue
            }
            // Skip messages that were cancelled by the user
            if cancelledOutgoingIds.remove(message.id) != nil {
                MessageRepository.shared.deleteMessage(id: message.id)
                continue
            }
            // Group messages don't get server ACK — skip retry, mark delivered.
            if DatabaseManager.isGroupDialogKey(message.toPublicKey) {
                MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .delivered)
                DialogRepository.shared.updateDeliveryStatus(
                    messageId: message.id, opponentKey: message.toPublicKey, status: .delivered
                )
                continue
            }

            // Decrypt stored text to recover original plaintext.
            // DB stores encryptWithPassword(plaintext, privateKey) — we must reverse this
            // to avoid double-encrypting (makeOutgoingPacket encrypts with XChaCha20).
            guard let plaintext = Self.recoverRetryPlaintext(
                storedText: message.text,
                privateKeyHex: privateKeyHex
            ) else {
                markRetryMessageAsError(
                    message,
                    reason: "plaintext_unrecoverable"
                )
                continue
            }

            let uploadBackedAttachments = message.attachments.filter {
                $0.type == .image || $0.type == .file || $0.type == .avatar
            }
            let uploadedAttachments = uploadBackedAttachments.filter {
                !$0.effectiveDownloadTag.isEmpty
            }
            let hasUploadBackedAttachments = !uploadBackedAttachments.isEmpty

            if hasUploadBackedAttachments,
               uploadedAttachments.count != uploadBackedAttachments.count {
                markRetryMessageAsError(
                    message,
                    reason: "missing_attachment_transport_tags"
                )
                continue
            }

            let hasPlaintext = !plaintext.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            guard hasUploadBackedAttachments || hasPlaintext else {
                continue
            }

            // Update dialog delivery status back to .waiting (shows clock icon).
            DialogRepository.shared.updateDeliveryStatus(
                messageId: message.id,
                opponentKey: message.toPublicKey,
                status: .waiting
            )

            do {
                // Fresh timestamp for retry: server silently rejects messages older than 30s
                // (Executor6Message maxPaddingSec=30). Original timestamp would fail validation
                // if reconnect took >30s. Server overwrites timestamp with System.currentTimeMillis()
                // anyway (Executor6Message:102), so client timestamp is only for age validation.
                let freshTimestamp = Int64(Date().timeIntervalSince1970 * 1000)

                if hasUploadBackedAttachments {
                    // ── Retry WITH attachments using ORIGINAL key+nonce ──
                    // CDN blob was encrypted with hex(originalKeyAndNonce).
                    // We MUST reuse the SAME key+nonce so the recipient derives the
                    // SAME PBKDF2 password and can decrypt the blob from CDN.
                    guard let storedPwd = message.attachmentPassword,
                          let originalKeyAndNonce = Self.rawKeyAndNonceFromStoredAttachmentPassword(storedPwd)
                    else {
                        markRetryMessageAsError(
                            message,
                            reason: "attachment_key_unrecoverable"
                        )
                        continue
                    }

                    let key = Data(originalKeyAndNonce.prefix(32))
                    let nonce = Data(originalKeyAndNonce.subdata(in: 32..<56))

                    // XChaCha20-encrypt text with ORIGINAL key+nonce
                    let ciphertextWithTag = try XChaCha20Engine.encrypt(
                        plaintext: Data(plaintext.utf8), key: key, nonce: nonce
                    )
                    let content = ciphertextWithTag.hexString

                    // ECDH-encrypt ORIGINAL key+nonce for recipient (fresh ephemeral key)
                    let chachaKey = try MessageCrypto.encryptKeyForRecipient(
                        keyAndNonce: originalKeyAndNonce,
                        recipientPublicKeyHex: message.toPublicKey
                    )

                    // aesChachaKey for sync (same derivation as original send)
                    guard let latin1String = String(data: originalKeyAndNonce, encoding: .isoLatin1) else {
                        throw CryptoError.encryptionFailed
                    }
                    let aesChachaPayload = Data(latin1String.utf8)
                    let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
                        aesChachaPayload, password: privateKeyHex
                    )

                    var packet = PacketMessage()
                    packet.fromPublicKey = currentPublicKey
                    packet.toPublicKey = message.toPublicKey
                    packet.content = content
                    packet.chachaKey = chachaKey
                    packet.timestamp = freshTimestamp
                    packet.privateKey = privateKeyHash
                    packet.messageId = message.id
                    packet.attachments = uploadedAttachments
                    packet.aesChachaKey = aesChachaKey

                    ProtocolManager.shared.sendPacket(packet)
                    registerOutgoingRetry(for: packet)
                    Self.logger.info("Retrying message+attachments \(message.id.prefix(8))… (\(uploadedAttachments.count) att) to \(message.toPublicKey.prefix(12))…")
                } else {
                    // ── Retry text-only (no upload-backed attachments) ──
                    let packet = try makeOutgoingPacket(
                        text: plaintext,
                        toPublicKey: message.toPublicKey,
                        messageId: message.id,
                        timestamp: freshTimestamp,
                        privateKeyHex: privateKeyHex,
                        privateKeyHash: privateKeyHash
                    )
                    ProtocolManager.shared.sendPacket(packet)
                    registerOutgoingRetry(for: packet)
                    Self.logger.info("Retrying message \(message.id.prefix(8))… to \(message.toPublicKey.prefix(12))…")
                }
            } catch {
                Self.logger.error("Failed to retry message \(message.id): \(error.localizedDescription)")
                MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error)
                DialogRepository.shared.updateDeliveryStatus(
                    messageId: message.id,
                    opponentKey: message.toPublicKey,
                    status: .error
                )
            }
        }
    }

    private static func recoverRetryPlaintext(
        storedText: String,
        privateKeyHex: String
    ) -> String? {
        if storedText.isEmpty { return "" }

        // Try decryption with compression first (standard path), then without.
        if let data = try? CryptoManager.shared.decryptWithPassword(
            storedText,
            password: privateKeyHex,
            requireCompression: true
        ),
           let decoded = String(data: data, encoding: .utf8) {
            return decoded
        }
        if let data = try? CryptoManager.shared.decryptWithPassword(
            storedText,
            password: privateKeyHex
        ),
           let decoded = String(data: data, encoding: .utf8) {
            return decoded
        }

        // Legacy fallback: allow plain text only if it doesn't look like ciphertext.
        if isLikelyEncryptedPayload(storedText) {
            return nil
        }
        return storedText
    }

    private static func isLikelyEncryptedPayload(_ value: String) -> Bool {
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty { return false }

        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
    }

    private static func rawKeyAndNonceFromStoredAttachmentPassword(_ stored: String) -> Data? {
        guard stored.hasPrefix("rawkey:") else { return nil }
        let hex = String(stored.dropFirst("rawkey:".count))
        guard let decoded = Data(strictHexString: hex), decoded.count == 56 else {
            return nil
        }
        return decoded
    }

    private func markRetryMessageAsError(_ message: ChatMessage, reason: String) {
        Self.logger.error("Retry ABORT: \(reason) for message \(message.id.prefix(8))…")
        MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error)
        DialogRepository.shared.updateDeliveryStatus(
            messageId: message.id,
            opponentKey: message.toPublicKey,
            status: .error
        )
    }

    private func registerOutgoingRetry(for packet: PacketMessage) {
        let messageId = packet.messageId
        pendingOutgoingRetryTasks[messageId]?.cancel()
        pendingOutgoingPackets[messageId] = packet
        pendingOutgoingAttempts[messageId] = 0
        scheduleOutgoingRetry(messageId: messageId)
    }

    private func scheduleOutgoingRetry(messageId: String) {
        pendingOutgoingRetryTasks[messageId]?.cancel()
        pendingOutgoingRetryTasks[messageId] = Task { @MainActor [weak self] in
            guard let self else { return }
            try? await Task.sleep(for: .seconds(4))

            guard let packet = self.pendingOutgoingPackets[messageId] else { return }
            let attempts = self.pendingOutgoingAttempts[messageId] ?? 0

            // Android parity: flat 80s timeout (MESSAGE_MAX_TIME_TO_DELIVERED_MS).
            let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
            let ageMs = nowMs - packet.timestamp
            if ageMs >= self.maxOutgoingWaitingLifetimeMs {
                // Android parity: mark as ERROR after timeout, not DELIVERED.
                // If the message was actually delivered, server will send 0x08 on reconnect
                // (or sync will restore the message). Marking DELIVERED optimistically
                // hides the problem from the user — they think it was sent but it wasn't.
                Self.logger.warning("Message \(messageId) — no ACK after \(ageMs)ms, marking as ERROR")
                self.markOutgoingAsError(messageId: messageId, packet: packet)
                return
            }

            guard attempts < self.maxOutgoingRetryAttempts else {
                // Android parity: mark as ERROR after max retries.
                // User can manually retry via error menu.
                Self.logger.warning("Message \(messageId) — no ACK after \(attempts) retries, marking as ERROR")
                self.markOutgoingAsError(messageId: messageId, packet: packet)
                return
            }

            guard ProtocolManager.shared.connectionState == .authenticated else {
                // Android parity: cancel retry when not authenticated — clean up in-memory state.
                // retryWaitingOutgoingMessagesAfterReconnect() re-reads from DB after sync completes.
                Self.logger.debug("Message \(messageId) retry deferred — not authenticated")
                self.resolveOutgoingRetry(messageId: messageId)
                return
            }

            let nextAttempt = attempts + 1
            self.pendingOutgoingAttempts[messageId] = nextAttempt
            Self.logger.warning("Retrying message \(messageId), attempt \(nextAttempt)")
            ProtocolManager.shared.sendPacket(packet)
            self.scheduleOutgoingRetry(messageId: messageId)
        }
    }

    /// Optimistically mark an outgoing message as delivered when no ACK was received
    /// but packets were successfully sent while authenticated. Most common cause:
    /// server deduplicates by messageId (original was delivered before disconnect).
    private func markOutgoingAsDelivered(messageId: String, packet: PacketMessage) {
        let fromMe = packet.fromPublicKey == currentPublicKey
        let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey

        let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
        MessageRepository.shared.updateDeliveryStatus(
            messageId: messageId,
            status: .delivered,
            newTimestamp: deliveryTimestamp
        )
        DialogRepository.shared.updateDeliveryStatus(
            messageId: messageId,
            opponentKey: opponentKey,
            status: .delivered
        )
        resolveOutgoingRetry(messageId: messageId)
    }

    /// Mark an outgoing message as error in both repositories and clean up retry state.
    /// Guards against downgrading .delivered/.read → .error (e.g. if PacketDelivery/PacketRead
    /// arrived while the retry timer was still running).
    private func markOutgoingAsError(messageId: String, packet: PacketMessage) {
        let fromMe = packet.fromPublicKey == currentPublicKey
        let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey

        let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
        if currentStatus == .delivered {
            Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already delivered")
            resolveOutgoingRetry(messageId: messageId)
            return
        }

        MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
        DialogRepository.shared.updateDeliveryStatus(
            messageId: messageId,
            opponentKey: opponentKey,
            status: .error
        )
        resolveOutgoingRetry(messageId: messageId)
    }

    private func resolveOutgoingRetry(messageId: String) {
        pendingOutgoingRetryTasks[messageId]?.cancel()
        pendingOutgoingRetryTasks.removeValue(forKey: messageId)
        pendingOutgoingPackets.removeValue(forKey: messageId)
        pendingOutgoingAttempts.removeValue(forKey: messageId)
    }

    /// Resolve all pending outgoing retries for messages to a specific opponent.
    /// Called when PacketRead (0x07) proves that messages were delivered.
    private func resolveAllOutgoingRetries(toPublicKey: String) {
        let matchingIds = pendingOutgoingPackets
            .filter { $0.value.toPublicKey == toPublicKey }
            .map { $0.key }
        for messageId in matchingIds {
            Self.logger.info("Resolving retry for \(messageId.prefix(8))… — read receipt received")
            resolveOutgoingRetry(messageId: messageId)
        }
    }

    // MARK: - Push Notifications

    /// Stores the FCM registration token received from Firebase Messaging delegate.
    func setAPNsToken(_ token: String) {
        let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedToken.isEmpty else { return }
        UserDefaults.standard.set(normalizedToken, forKey: "apns_device_token")
        // If already authenticated, send immediately
        if ProtocolManager.shared.connectionState == .authenticated {
            sendPushTokenToServer(force: true)
        }
    }

    /// Sends the stored FCM push token to the server via PacketPushNotification (0x10).
    /// Android parity: called after successful handshake.
    private func sendPushTokenToServer(force: Bool = false) {
        guard let token = UserDefaults.standard.string(forKey: "apns_device_token"),
              !token.isEmpty,
              ProtocolManager.shared.connectionState == .authenticated
        else { return }

        let now = Date().timeIntervalSince1970
        if !force,
           let lastPushTokenSubscribe,
           lastPushTokenSubscribe.token == token,
           now - lastPushTokenSubscribe.sentAt < 5 {
            return
        }

        var packet = PacketPushNotification()
        packet.notificationsToken = token
        packet.action = .subscribe
        packet.tokenType = .fcm
        packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
        ProtocolManager.shared.sendPacket(packet)
        lastPushTokenSubscribe = (token, now)
        Self.logger.info("FCM push token sent to server")
    }

    // MARK: - VoIP Push Token (PushKit)

    /// Stores the VoIP push token received from PushKit.
    func setVoIPToken(_ token: String) {
        let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedToken.isEmpty else { return }
        UserDefaults.standard.set(normalizedToken, forKey: "voip_push_token")
        if ProtocolManager.shared.connectionState == .authenticated {
            sendVoIPTokenToServer(force: true)
        }
    }

    /// Sends the stored VoIP push token to the server via PacketPushNotification (0x10).
    private func sendVoIPTokenToServer(force: Bool = false) {
        guard let token = UserDefaults.standard.string(forKey: "voip_push_token"),
              !token.isEmpty,
              ProtocolManager.shared.connectionState == .authenticated
        else { return }

        let now = Date().timeIntervalSince1970
        if !force,
           let lastVoIPTokenSubscribe,
           lastVoIPTokenSubscribe.token == token,
           now - lastVoIPTokenSubscribe.sentAt < 5 {
            return
        }

        var packet = PacketPushNotification()
        packet.notificationsToken = token
        packet.action = .subscribe
        packet.tokenType = .voipApns
        packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
        ProtocolManager.shared.sendPacket(packet)
        lastVoIPTokenSubscribe = (token, now)
        Self.logger.info("VoIP push token sent to server")
    }

    /// Sends unsubscribe for FCM token (called on account switch/logout).
    private func unsubscribeFCMToken(_ token: String) {
        guard !token.isEmpty,
              ProtocolManager.shared.connectionState == .authenticated
        else { return }

        var packet = PacketPushNotification()
        packet.notificationsToken = token
        packet.action = .unsubscribe
        packet.tokenType = .fcm
        packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
        ProtocolManager.shared.sendPacket(packet)
        Self.logger.info("FCM token unsubscribed from server")
    }

    /// Sends unsubscribe for a stale VoIP token and clears local storage.
    func unsubscribeVoIPToken(_ token: String) {
        let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedToken.isEmpty else { return }
        if ProtocolManager.shared.connectionState == .authenticated {
            var packet = PacketPushNotification()
            packet.notificationsToken = normalizedToken
            packet.action = .unsubscribe
            packet.tokenType = .voipApns
            packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
            ProtocolManager.shared.sendPacket(packet)
            Self.logger.info("VoIP token unsubscribed from server")
        }
        UserDefaults.standard.removeObject(forKey: "voip_push_token")
        if let lastVoIPTokenSubscribe, lastVoIPTokenSubscribe.token == normalizedToken {
            self.lastVoIPTokenSubscribe = nil
        }
    }

    // MARK: - Release Notes (Desktop Parity)

    /// Desktop parity: sends release notes as a local system message from
    /// the "Rosetta Updates" account when the app version changes.
    /// Desktop equivalent: `useUpdateMessage.ts` → `useSendSystemMessage("updates")`.
    private func sendReleaseNotesIfNeeded(publicKey: String) {
        let key = "lastReleaseNoticeVersion_\(publicKey)"
        let lastKey = UserDefaults.standard.string(forKey: key) ?? ""
        // Android parity: version + text hash — re-sends if text changed within same version.
        let noticeText = ReleaseNotes.releaseNoticeText
        guard !noticeText.isEmpty else { return }
        // Use stable hash — Swift's hashValue is randomized per launch (ASLR seed).
        // Android uses Java's hashCode() which is deterministic.
        let stableHash = noticeText.utf8.reduce(0) { ($0 &* 31) &+ Int($1) }
        let currentKey = "\(ReleaseNotes.appVersion)_\(stableHash)"

        guard lastKey != currentKey else { return }

        let now = Int64(Date().timeIntervalSince1970 * 1000)
        let messageId = "release_notes_\(ReleaseNotes.appVersion)"

        // Create synthetic PacketMessage — local-only, never sent to server.
        var packet = PacketMessage()
        packet.fromPublicKey = Account.updatesPublicKey
        packet.toPublicKey = publicKey
        packet.timestamp = now
        packet.messageId = messageId

        // Insert message into MessageRepository (delivered, already read).
        MessageRepository.shared.upsertFromMessagePacket(
            packet,
            myPublicKey: publicKey,
            decryptedText: noticeText,
            fromSync: true // fromSync = true → message marked as delivered + read
        )

        // Android parity: recalculate dialog from DB after inserting release note.
        DialogRepository.shared.updateDialogFromMessages(opponentKey: Account.updatesPublicKey)
        // Force-clear unread for system Updates account — release notes are always "read".
        DialogRepository.shared.markAsRead(opponentKey: Account.updatesPublicKey)

        // Set system account display info (title, username, verified badge).
        // Desktop parity: both system accounts use verified=1 (blue rosette badge).
        DialogRepository.shared.updateUserInfo(
            publicKey: Account.updatesPublicKey,
            title: SystemAccounts.updatesTitle,
            username: "updates",
            verified: 1
        )

        UserDefaults.standard.set(currentKey, forKey: key)
        Self.logger.info("Release notes v\(ReleaseNotes.appVersion) sent to Updates chat")
    }

    // MARK: - Foreground Observer (Android parity)

    private func setupForegroundObserver() {
        // Android parity: ON_RESUME → markVisibleMessagesAsRead() + reconnect + sync.
        foregroundObserverToken = NotificationCenter.default.addObserver(
            forName: UIApplication.willEnterForegroundNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor [weak self] in
                // Android parity (onResume line 428): clear ALL delivered notifications
                UNUserNotificationCenter.current().removeAllDeliveredNotifications()
                // Reconcile badge immediately from DB — NSE may have stale count
                // (e.g. duplicate push inflated badge, or read push didn't decrement).
                // Must happen BEFORE sync, so user sees correct badge instantly.
                DialogRepository.shared.reconcileUnreadCounts()
                // Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
                // Safe after background clear: readEligibleDialogs is empty on resume,
                // so this is a no-op until ChatDetailView re-evaluates eligibility.
                self?.markActiveDialogsAsRead()

                // Android parity: on foreground resume, always force reconnect.
                // Android's OkHttp fires onFailure in background → state is accurate.
                // iOS URLSession does NOT fire didCloseWith when process is suspended →
                // connectionState stays .authenticated (zombie socket). Ping-first
                // added 3 seconds of delay waiting for pong that never comes.
                // Match Android: just tear down + reconnect. If connection was alive,
                // small cost (reconnect <1s). If zombie, no wasted 3s.
                ProtocolManager.shared.forceReconnectOnForeground()

                // syncOnForeground() has its own `.authenticated` guard — safe to call.
                // If ping-first triggers reconnect, sync won't fire (state is .connecting).
                // After reconnect + handshake, onHandshakeCompleted triggers requestSynchronize().
                self?.syncOnForeground()
            }
        }

        // Clear read eligibility when app enters background.
        // Prevents stale readEligibleDialogs from marking messages as read
        // during background sync or on premature foreground resume.
        // ChatDetailView re-evaluates eligibility via didBecomeActiveNotification.
        backgroundObserverToken = NotificationCenter.default.addObserver(
            forName: UIApplication.didEnterBackgroundNotification,
            object: nil,
            queue: .main
        ) { _ in
            MessageRepository.shared.clearAllReadEligibility()
        }
    }

    /// Android parity: `syncOnForeground()` — request sync on foreground resume
    /// if already authenticated and 5s have passed since last foreground sync.
    private func syncOnForeground() {
        guard ProtocolManager.shared.connectionState == .authenticated else { return }
        guard !syncBatchInProgress else { return }
        guard !syncRequestInFlight else { return }
        let now = Date().timeIntervalSince1970
        guard now - lastForegroundSyncTime >= 5 else { return }
        lastForegroundSyncTime = now
        Self.logger.info("🔄 Sync on foreground resume")
        requestSynchronize()
    }
}
