import Foundation
import Observation
import UserNotifications
import GRDB

/// Account-scoped dialog store backed by SQLite (GRDB).
/// Android parity: `DialogEntity` + `updateDialogFromMessages()` transaction.
@Observable
@MainActor
final class DialogRepository {

    static let shared = DialogRepository()

    private(set) var dialogs: [String: Dialog] = [:] {
        didSet {
            // PERF: Only invalidate the data snapshot (not sort order).
            // Sort order (_sortedKeysCache) is invalidated explicitly only when
            // isPinned, lastMessageTimestamp, or dialog count changes.
            _sortedDialogsCache = nil
            dialogsVersion &+= 1
        }
    }

    /// Call when sort order may have changed (pin/unpin, new message, add/remove dialog).
    private func invalidateSortOrder() {
        _sortedKeysCache = nil
        _sortedDialogsCache = nil
    }

    @ObservationIgnored private(set) var dialogsVersion: Int = 0
    private var currentAccount: String = ""

    // MARK: - Dirty Tracking (PERF)

    /// Keys that need reconciliation (populated during sync).
    @ObservationIgnored private var dirtyReconcileKeys: Set<String> = []

    /// Track a dialog key that needs reconciliation after sync.
    func markDirtyForReconcile(_ opponentKey: String) {
        dirtyReconcileKeys.insert(opponentKey)
    }

    // MARK: - UserDefaults Debounce (PERF)

    @ObservationIgnored private var debouncedMutedWork: DispatchWorkItem?
    @ObservationIgnored private var debouncedContactsWork: DispatchWorkItem?

    // MARK: - Sort Caches

    @ObservationIgnored private var _sortedKeysCache: [String]?
    @ObservationIgnored private var _sortedDialogsCache: [Dialog]?

    var sortedDialogs: [Dialog] {
        if let cached = _sortedDialogsCache { return cached }

        let result: [Dialog]
        if let keys = _sortedKeysCache {
            result = keys.compactMap { dialogs[$0] }
        } else {
            result = Array(dialogs.values).sorted {
                if $0.isPinned != $1.isPinned { return $0.isPinned }
                return $0.lastMessageTimestamp > $1.lastMessageTimestamp
            }
            _sortedKeysCache = result.map(\.opponentKey)
        }

        _sortedDialogsCache = result
        return result
    }

    private var db: DatabaseManager { DatabaseManager.shared }

    private init() {}

    // MARK: - Lifecycle

    func bootstrap(accountPublicKey: String, storagePassword: String) async {
        let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !account.isEmpty else {
            reset()
            return
        }

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

        currentAccount = account

        // Load all dialogs from SQLite into memory
        do {
            let records = try db.read { db in
                try DialogRecord
                    .filter(DialogRecord.Columns.account == account)
                    .fetchAll(db)
            }
            dialogs = Dictionary(
                uniqueKeysWithValues: records.map { ($0.opponentKey, $0.toDialog()) }
            )
        } catch {
            print("[DB] DialogRepository bootstrap error: \(error)")
            dialogs = [:]
        }
        invalidateSortOrder()
        updateAppBadge()
        syncMutedKeysToDefaults(immediate: true)
        syncContactNamesToDefaults(immediate: true)
    }

    func reset(clearPersisted: Bool = false) {
        dialogs.removeAll()
        invalidateSortOrder()
        UNUserNotificationCenter.current().setBadgeCount(0)
        UserDefaults.standard.set(0, forKey: "app_badge_count")
        UserDefaults(suiteName: "group.com.rosetta.dev")?.set(0, forKey: "app_badge_count")

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

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

    // MARK: - Updates

    func upsertDialog(_ dialog: Dialog) {
        if currentAccount.isEmpty { currentAccount = dialog.account }
        let existing = dialogs[dialog.opponentKey]
        dialogs[dialog.opponentKey] = dialog
        // PERF: Only invalidate sort order when ordering fields change.
        if existing == nil
            || existing?.isPinned != dialog.isPinned
            || existing?.lastMessageTimestamp != dialog.lastMessageTimestamp {
            invalidateSortOrder()
        }
        persistDialog(dialog)
    }

    /// Android parity 1:1: `dialogDao.updateDialogFromMessages(account, opponentKey)`.
    /// Full recalculation of ALL computed dialog fields from DB in one pass.
    /// Called after EVERY message insert, delivery ACK, read receipt, and markAsRead.
    func updateDialogFromMessages(opponentKey: String) {
        guard !currentAccount.isEmpty else { return }
        let account = currentAccount
        let isSystem = SystemAccounts.isSystemAccount(opponentKey)
        let isSavedMessages = account == opponentKey
        let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey)

        // Preserve fields not derived from messages
        let existing = dialogs[opponentKey]

        // 1. Last message from DB — DECRYPTED (text in DB is encrypted with privateKey)
        guard let lastMsg = MessageRepository.shared.lastDecryptedMessage(account: account, opponentKey: opponentKey) else {
            // No messages — remove dialog if it exists
            if existing != nil {
                dialogs.removeValue(forKey: opponentKey)
                invalidateSortOrder()
                do {
                    try db.writeSync { db in
                        try db.execute(
                            sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?",
                            arguments: [account, opponentKey]
                        )
                    }
                } catch {}
                scheduleAppBadgeUpdate()
            }
            return
        }

        // 2. Unread count from DB (Android: countUnreadByDialogKey)
        let unread: Int
        if isSavedMessages {
            unread = 0 // Android parity: saved messages always 0 unread
        } else {
            unread = MessageRepository.shared.countUnread(account: account, opponentKey: opponentKey)
        }

        // 3. hasSent from DB (Android: hasSentByDialogKey)
        let hasSent = MessageRepository.shared.hasSentMessages(account: account, opponentKey: opponentKey)

        // 4. Last message display text
        // Android parity: if text is empty/garbage, show attachment type label.
        let textIsEmpty = lastMsg.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            || Self.isGarbageText(lastMsg.text)
        let lastMessageText: String
        if textIsEmpty, let firstAtt = lastMsg.attachments.first {
            switch firstAtt.type {
            case .image: lastMessageText = "Photo"
            case .file:  lastMessageText = "File"
            case .avatar: lastMessageText = "Avatar"
            case .messages: lastMessageText = "Forwarded message"
            case .call: lastMessageText = "Call"
            case .voice: lastMessageText = "Voice message"
            @unknown default: lastMessageText = "Attachment"
            }
        } else if textIsEmpty {
            lastMessageText = ""
        } else if lastMsg.text.hasPrefix("$a=") {
            // Service message (Desktop parity): strip $a= prefix for display
            let action = String(lastMsg.text.dropFirst(3))
            switch action {
            case "Group created": lastMessageText = "Group created"
            case "Group joined": lastMessageText = "You joined the group"
            default: lastMessageText = action
            }
        } else {
            lastMessageText = lastMsg.text
        }

        let lastFromMe = lastMsg.fromPublicKey == account

        // 5. Build dialog — preserve non-message fields from existing
        var dialog = existing ?? Dialog(
            id: opponentKey, account: account, opponentKey: opponentKey,
            opponentTitle: "", opponentUsername: "",
            lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
            isOnline: false, lastSeen: 0, verified: 0,
            iHaveSent: false, isPinned: false, isMuted: false,
            lastMessageFromMe: false, lastMessageDelivered: .waiting,
            lastMessageRead: false
        )

        // Update computed fields
        dialog.lastMessage = lastMessageText
        dialog.lastMessageTimestamp = lastMsg.timestamp
        dialog.unreadCount = unread
        // DB+Flow Safe: group dialogs are always treated as chat (not request).
        dialog.iHaveSent = hasSent || isSystem || isGroupDialog
        dialog.lastMessageFromMe = lastFromMe
        dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
        // Android parity: separate read flag from last outgoing message's is_read column.
        dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false
        // Group sender key for "Alice: hey" chat list preview
        dialog.lastMessageSenderKey = lastMsg.fromPublicKey

        let oldTimestamp = existing?.lastMessageTimestamp
        dialogs[opponentKey] = dialog
        // PERF: Only invalidate sort when timestamp or pin status changed.
        if existing == nil || oldTimestamp != dialog.lastMessageTimestamp || existing?.isPinned != dialog.isPinned {
            invalidateSortOrder()
        }
        persistDialog(dialog)
        scheduleAppBadgeUpdate()
    }

    /// - Parameter skipPersist: When true, skips DB write and UserDefaults sync.
    ///   Used during sync (BATCH_START → BATCH_END) — reconcileAllDialogs() at BATCH_END
    ///   does a single persistDialogsBatch + syncContactNamesToDefaults instead of N individual writes.
    func ensureDialog(
        opponentKey: String, title: String, username: String,
        verified: Int = 0, myPublicKey: String,
        skipPersist: Bool = false
    ) {
        let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey)
        if var existing = dialogs[opponentKey] {
            var changed = false
            if !title.isEmpty, existing.opponentTitle != title {
                existing.opponentTitle = title; changed = true
            }
            if !username.isEmpty, existing.opponentUsername != username {
                existing.opponentUsername = username; changed = true
            }
            if verified > existing.verified {
                existing.verified = verified; changed = true
            }
            if isGroupDialog, !existing.iHaveSent {
                existing.iHaveSent = true; changed = true
            }
            guard changed else { return }
            dialogs[opponentKey] = existing
            if !skipPersist {
                persistDialog(existing)
                syncContactNamesToDefaults()
            }
            return
        }

        let dialog = Dialog(
            id: opponentKey, account: myPublicKey, opponentKey: opponentKey,
            opponentTitle: title, opponentUsername: username,
            lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
            isOnline: false, lastSeen: 0, verified: verified,
            iHaveSent: isGroupDialog, isPinned: false, isMuted: false,
            lastMessageFromMe: false, lastMessageDelivered: .waiting,
            lastMessageRead: false
        )
        dialogs[opponentKey] = dialog
        invalidateSortOrder()  // New dialog added
        if !skipPersist {
            persistDialog(dialog)
            syncContactNamesToDefaults()
        }
    }

    func updateOnlineState(publicKey: String, isOnline: Bool) {
        guard var dialog = dialogs[publicKey] else { return }
        guard dialog.isOnline != isOnline else { return }
        dialog.isOnline = isOnline
        if !isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) }
        dialogs[publicKey] = dialog
        persistDialog(dialog)
    }

    /// PERF: Batch update online state — single `dialogs` mutation + single DB transaction.
    /// Used for OnlineState subscribe response (up to 20 entries in one packet).
    func batchUpdateOnlineState(entries: [(publicKey: String, isOnline: Bool)]) {
        let now = Int64(Date().timeIntervalSince1970 * 1000)
        var batch = dialogs
        var changed: [Dialog] = []
        for entry in entries {
            guard var dialog = batch[entry.publicKey] else { continue }
            guard dialog.isOnline != entry.isOnline else { continue }
            dialog.isOnline = entry.isOnline
            if !entry.isOnline { dialog.lastSeen = now }
            batch[entry.publicKey] = dialog
            changed.append(dialog)
        }
        guard !changed.isEmpty else { return }
        dialogs = batch  // Single observation trigger
        persistDialogsBatch(changed)
    }

    /// Legacy shim — calls updateDialogFromMessages() for full recalculation.
    func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) {
        updateDialogFromMessages(opponentKey: opponentKey)
    }

    func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) {
        guard var dialog = dialogs[publicKey] else { return }
        var changed = false
        var nameChanged = false
        if !title.isEmpty, dialog.opponentTitle != title { dialog.opponentTitle = title; changed = true; nameChanged = true }
        if !username.isEmpty, dialog.opponentUsername != username { dialog.opponentUsername = username; changed = true; nameChanged = true }
        if verified > 0, dialog.verified < verified { dialog.verified = verified; changed = true }
        if online >= 0 {
            let newOnline = online == 0
            if dialog.isOnline != newOnline {
                dialog.isOnline = newOnline
                if !newOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) }
                changed = true
            }
        }
        guard changed else { return }
        dialogs[publicKey] = dialog
        persistDialog(dialog)
        if nameChanged { syncContactNamesToDefaults() }
    }

    /// Android parity: recalculate dialog from DB after marking messages as read.
    func markAsRead(opponentKey: String) {
        updateDialogFromMessages(opponentKey: opponentKey)
        // Clear mention when dialog is read
        setMention(opponentKey: opponentKey, hasMention: false)
    }

    /// Desktop parity: set/clear mention indicator on a dialog.
    func setMention(opponentKey: String, hasMention: Bool) {
        guard var dialog = dialogs[opponentKey] else { return }
        if dialog.hasMention != hasMention {
            dialog.hasMention = hasMention
            dialogs[opponentKey] = dialog
        }
    }

    /// Android parity: recalculate dialog from DB after opponent reads our messages.
    func markOutgoingAsRead(opponentKey: String) {
        updateDialogFromMessages(opponentKey: opponentKey)
    }

    func deleteDialog(opponentKey: String) {
        dialogs.removeValue(forKey: opponentKey)
        invalidateSortOrder()
        do {
            try db.writeSync { db in
                try db.execute(
                    sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?",
                    arguments: [currentAccount, opponentKey]
                )
            }
        } catch {
            print("[DB] deleteDialog error: \(error)")
        }
        scheduleAppBadgeUpdate()
    }

    /// Android parity: full recalculation after message delete.
    func reconcileAfterMessageDelete(opponentKey: String) {
        updateDialogFromMessages(opponentKey: opponentKey)
    }

    func removeDialogIfEmpty(opponentKey: String) {
        let messages = MessageRepository.shared.messages(for: opponentKey)
        if messages.isEmpty {
            dialogs.removeValue(forKey: opponentKey)
            invalidateSortOrder()
            do {
                try db.writeSync { db in
                    try db.execute(
                        sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?",
                        arguments: [currentAccount, opponentKey]
                    )
                }
            } catch {}
            scheduleAppBadgeUpdate()
        }
    }

    func togglePin(opponentKey: String) {
        guard var dialog = dialogs[opponentKey] else { return }
        if !dialog.isPinned {
            let pinnedCount = dialogs.values.filter(\.isPinned).count
            if pinnedCount >= ProtocolConstants.maxPinnedDialogs { return }
        }
        dialog.isPinned.toggle()
        var updatedDialogs = dialogs
        updatedDialogs[opponentKey] = dialog
        dialogs = updatedDialogs
        invalidateSortOrder()  // Pin status changed
        persistDialog(dialog)
    }

    /// Android parity: recalculate dialogs that changed during sync.
    /// PERF: Batches all mutations into a single `dialogs` assignment
    /// to trigger `didSet` (and observation) exactly once, not N times.
    func reconcileAllDialogs() {
        let keysToReconcile = dirtyReconcileKeys.isEmpty
            ? Set(dialogs.keys)
            : dirtyReconcileKeys
        dirtyReconcileKeys.removeAll()

        guard !keysToReconcile.isEmpty else { return }

        var batch = dialogs
        var sortOrderChanged = false

        var dialogsToPersist: [Dialog] = []
        var keysToDelete: [String] = []

        for opponentKey in keysToReconcile {
            let account = currentAccount
            let isSavedMessages = opponentKey == account
            let isSystem = SystemAccounts.isSystemAccount(opponentKey)
            let isGroupDialog = GroupRepository.shared.isGroupDialog(opponentKey)

            guard let lastMsg = MessageRepository.shared.lastDecryptedMessage(
                account: account, opponentKey: opponentKey
            ) else {
                if batch[opponentKey] != nil {
                    batch.removeValue(forKey: opponentKey)
                    sortOrderChanged = true
                    keysToDelete.append(opponentKey)
                }
                continue
            }

            let unread: Int = isSavedMessages ? 0
                : MessageRepository.shared.countUnread(account: account, opponentKey: opponentKey)
            let hasSent = MessageRepository.shared.hasSentMessages(account: account, opponentKey: opponentKey)

            let textIsEmpty = lastMsg.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
                || Self.isGarbageText(lastMsg.text)
            let lastMessageText: String
            if textIsEmpty, let firstAtt = lastMsg.attachments.first {
                switch firstAtt.type {
                case .image: lastMessageText = "Photo"
                case .file:  lastMessageText = "File"
                case .avatar: lastMessageText = "Avatar"
                case .messages: lastMessageText = "Forwarded message"
                case .call: lastMessageText = "Call"
                case .voice: lastMessageText = "Voice message"
                @unknown default: lastMessageText = "Attachment"
                }
            } else if textIsEmpty {
                lastMessageText = ""
            } else if lastMsg.text.hasPrefix("$a=") {
                let action = String(lastMsg.text.dropFirst(3))
                switch action {
                case "Group created": lastMessageText = "Group created"
                case "Group joined": lastMessageText = "You joined the group"
                default: lastMessageText = action
                }
            } else {
                lastMessageText = lastMsg.text
            }

            let lastFromMe = lastMsg.fromPublicKey == account
            var dialog = batch[opponentKey] ?? Dialog(
                id: opponentKey, account: account, opponentKey: opponentKey,
                opponentTitle: "", opponentUsername: "",
                lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
                isOnline: false, lastSeen: 0, verified: 0,
                iHaveSent: false, isPinned: false, isMuted: false,
                lastMessageFromMe: false, lastMessageDelivered: .waiting,
                lastMessageRead: false
            )

            let oldTimestamp = batch[opponentKey]?.lastMessageTimestamp
            dialog.lastMessage = lastMessageText
            dialog.lastMessageTimestamp = lastMsg.timestamp
            dialog.unreadCount = unread
            dialog.iHaveSent = hasSent || isSystem || isGroupDialog
            dialog.lastMessageFromMe = lastFromMe
            dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
            dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false
            dialog.lastMessageSenderKey = lastMsg.fromPublicKey

            if batch[opponentKey] == nil || oldTimestamp != dialog.lastMessageTimestamp
                || batch[opponentKey]?.isPinned != dialog.isPinned {
                sortOrderChanged = true
            }
            batch[opponentKey] = dialog
            dialogsToPersist.append(dialog)
        }

        // PERF: Single DB transaction for all dialog upserts + deletes
        // instead of N separate writeSync calls (was: 50 dialogs = 50 transactions).
        persistDialogsBatch(dialogsToPersist, keysToDelete: keysToDelete)

        // Single assignment — triggers didSet exactly once
        dialogs = batch
        if sortOrderChanged { invalidateSortOrder() }
        scheduleAppBadgeUpdate()
        // Sync deferred names from ensureDialog(skipPersist:true) calls during sync batch.
        syncContactNamesToDefaults()
    }

    /// Legacy shims — both route to reconcileAllDialogs (no-op if dirty set empty).
    func reconcileUnreadCounts() { reconcileAllDialogs() }
    func reconcileDeliveryStatuses() { /* handled by reconcileAllDialogs() */ }

    func toggleMute(opponentKey: String) {
        guard var dialog = dialogs[opponentKey] else { return }
        dialog.isMuted.toggle()
        dialogs[opponentKey] = dialog
        syncMutedKeysToDefaults(immediate: true)
        persistDialog(dialog)
        scheduleAppBadgeUpdate()
    }

    // MARK: - Private

    /// PERF: Batch upsert + delete in a single DB transaction.
    /// Used by reconcileAllDialogs() — reduces N writeSync calls to 1.
    private func persistDialogsBatch(_ dialogs: [Dialog], keysToDelete: [String] = []) {
        guard !dialogs.isEmpty || !keysToDelete.isEmpty else { return }
        do {
            try db.writeSync { db in
                for key in keysToDelete {
                    try db.execute(
                        sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?",
                        arguments: [currentAccount, key]
                    )
                }
                for dialog in dialogs {
                    try db.execute(
                        sql: """
                            INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username,
                                last_message, last_message_timestamp, unread_count, is_online, last_seen,
                                verified, i_have_sent, is_pinned, is_muted, last_message_from_me,
                                last_message_delivered, last_message_read, last_message_sender_key)
                            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                            ON CONFLICT(account, opponent_key) DO UPDATE SET
                                opponent_title = excluded.opponent_title,
                                opponent_username = excluded.opponent_username,
                                last_message = excluded.last_message,
                                last_message_timestamp = excluded.last_message_timestamp,
                                unread_count = excluded.unread_count,
                                is_online = excluded.is_online,
                                last_seen = excluded.last_seen,
                                verified = excluded.verified,
                                i_have_sent = excluded.i_have_sent,
                                is_pinned = excluded.is_pinned,
                                is_muted = excluded.is_muted,
                                last_message_from_me = excluded.last_message_from_me,
                                last_message_delivered = excluded.last_message_delivered,
                                last_message_read = excluded.last_message_read,
                                last_message_sender_key = excluded.last_message_sender_key
                            """,
                        arguments: [
                            dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername,
                            dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount,
                            dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified,
                            dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0,
                            dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue,
                            dialog.lastMessageRead ? 1 : 0, dialog.lastMessageSenderKey
                        ]
                    )
                }
            }
        } catch {
            print("[DB] persistDialogsBatch error: \(error)")
        }
    }

    private func persistDialog(_ dialog: Dialog) {
        do {
            try db.writeSync { db in
                // UPSERT: insert or replace based on (account, opponent_key) unique index
                try db.execute(
                    sql: """
                        INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username,
                            last_message, last_message_timestamp, unread_count, is_online, last_seen,
                            verified, i_have_sent, is_pinned, is_muted, last_message_from_me,
                            last_message_delivered, last_message_read, last_message_sender_key)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                        ON CONFLICT(account, opponent_key) DO UPDATE SET
                            opponent_title = excluded.opponent_title,
                            opponent_username = excluded.opponent_username,
                            last_message = excluded.last_message,
                            last_message_timestamp = excluded.last_message_timestamp,
                            unread_count = excluded.unread_count,
                            is_online = excluded.is_online,
                            last_seen = excluded.last_seen,
                            verified = excluded.verified,
                            i_have_sent = excluded.i_have_sent,
                            is_pinned = excluded.is_pinned,
                            is_muted = excluded.is_muted,
                            last_message_from_me = excluded.last_message_from_me,
                            last_message_delivered = excluded.last_message_delivered,
                            last_message_read = excluded.last_message_read,
                            last_message_sender_key = excluded.last_message_sender_key
                        """,
                    arguments: [
                        dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername,
                        dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount,
                        dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified,
                        dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0,
                        dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue,
                        dialog.lastMessageRead ? 1 : 0, dialog.lastMessageSenderKey
                    ]
                )
            }
        } catch {
            print("[DB] persistDialog error: \(error)")
        }
    }

    private func lastMessageFromDB(dialogKey: String) -> ChatMessage? {
        do {
            return try db.read { db in
                try MessageRecord
                    .filter(
                        MessageRecord.Columns.account == currentAccount &&
                        MessageRecord.Columns.dialogKey == dialogKey
                    )
                    .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
                    .fetchOne(db)?
                    .toChatMessage()
            }
        } catch { return nil }
    }

    private func syncMutedKeysToDefaults(immediate: Bool = false) {
        debouncedMutedWork?.cancel()
        let work = DispatchWorkItem { [weak self] in
            guard let self else { return }
            Task { @MainActor [weak self] in
                self?.performSyncMutedKeysToDefaults()
            }
        }
        debouncedMutedWork = work
        if immediate {
            work.perform()
        } else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: work)
        }
    }

    private func performSyncMutedKeysToDefaults() {
        var mutedKeys: [String] = []
        for dialog in dialogs.values where dialog.isMuted {
            mutedKeys.append(dialog.opponentKey)
            // Server sends group ID without #group: prefix in push `from` field.
            // Store stripped key so push mute check matches both formats.
            if dialog.opponentKey.lowercased().hasPrefix("#group:") {
                let stripped = String(dialog.opponentKey.dropFirst("#group:".count))
                if !stripped.isEmpty {
                    mutedKeys.append(stripped)
                    mutedKeys.append("group:\(stripped)")
                }
            }
        }
        UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys")
        UserDefaults(suiteName: "group.com.rosetta.dev")?.set(mutedKeys, forKey: "muted_chats_keys")
    }

    /// Sync contact display names to App Group for push notification name resolution.
    /// Both NSE and AppDelegate read this in background to show sender name in notification title.
    /// PERF: Debounced by 2s to avoid UserDefaults spam during sync.
    private func syncContactNamesToDefaults(immediate: Bool = false) {
        debouncedContactsWork?.cancel()
        let work = DispatchWorkItem { [weak self] in
            guard let self else { return }
            Task { @MainActor [weak self] in
                self?.performSyncContactNamesToDefaults()
            }
        }
        debouncedContactsWork = work
        if immediate {
            work.perform()
        } else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: work)
        }
    }

    private func performSyncContactNamesToDefaults() {
        var names: [String: String] = [:]
        for dialog in dialogs.values {
            let name = dialog.opponentTitle.isEmpty ? dialog.opponentUsername : dialog.opponentTitle
            if !name.isEmpty {
                names[dialog.opponentKey] = name
                // Server sends group ID without #group: prefix in push `from` field.
                // Store stripped key so push name lookup matches both formats.
                if dialog.opponentKey.lowercased().hasPrefix("#group:") {
                    let stripped = String(dialog.opponentKey.dropFirst("#group:".count))
                    if !stripped.isEmpty {
                        names[stripped] = name
                        names["group:\(stripped)"] = name
                    }
                }
            }
        }
        UserDefaults.standard.set(names, forKey: "contact_display_names")
        UserDefaults(suiteName: "group.com.rosetta.dev")?.set(names, forKey: "contact_display_names")
    }

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

    // MARK: - Badge

    @ObservationIgnored private var badgeUpdateTask: Task<Void, Never>?
    @ObservationIgnored private var lastBadgeTotal: Int = -1

    private func scheduleAppBadgeUpdate() {
        badgeUpdateTask?.cancel()
        badgeUpdateTask = Task { @MainActor [weak self] in
            try? await Task.sleep(for: .milliseconds(500))
            guard !Task.isCancelled, let self else { return }
            self.updateAppBadge()
        }
    }

    private func updateAppBadge() {
        let total = dialogs.values.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
        guard total != lastBadgeTotal else { return }
        lastBadgeTotal = total
        UNUserNotificationCenter.current().setBadgeCount(total)
        let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
        shared?.set(total, forKey: "app_badge_count")
        UserDefaults.standard.set(total, forKey: "app_badge_count")
    }

    // MARK: - Garbage Text Detection

    /// Returns true if text is empty, looks like encrypted ciphertext,
    /// or contains only garbage characters (replacement chars, control chars, null bytes).
    /// Must stay aligned with MessageCellLayout.isGarbageOrEncrypted() and
    /// MessageRepository.isProbablyEncryptedPayload().
    private static func isGarbageText(_ text: String) -> Bool {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty { return true }

        // Detect encrypted payload: ivBase64:ctBase64 format
        let parts = trimmed.components(separatedBy: ":")
        if parts.count == 2 {
            let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
            let bothBase64 = parts.allSatisfy { part in
                part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
            }
            if bothBase64 { return true }
        }
        // CHNK: chunked format
        if trimmed.hasPrefix("CHNK:") { return true }

        // Pure hex string (≥40 chars) — XChaCha20 wire format
        if trimmed.count >= 40 {
            let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
            if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
        }

        // Original check: all characters are garbage (U+FFFD, control chars, null bytes)
        let validCharacters = trimmed.unicodeScalars.filter { scalar in
            scalar.value != 0xFFFD &&
            scalar.value > 0x1F &&
            scalar.value != 0x7F &&
            !CharacterSet.controlCharacters.contains(scalar)
        }
        return validCharacters.isEmpty
    }
}
