import Foundation
import CryptoKit
import UIKit

/// Notification types from the server
enum ChatNotification {
    case newMessage(data: [String: Any])
    case messagesRead(data: [String: Any])
    case messageDeleted(data: [String: Any])
    case conversationCreated(data: [String: Any])
    case memberAdded(data: [String: Any])
    case memberRemoved(data: [String: Any])
    case userOnline(userId: String)
    case userOffline(userId: String)
    case onlineUsers(userIds: [String])
    case groupInvitation(data: [String: Any])
    case conversationRenamed(data: [String: Any])
    case sessionReset(data: [String: Any])
    case messageReacted(data: [String: Any])
    case messagePinned(data: [String: Any])
    case messageUnpinned(data: [String: Any])
    case messageDelivered(data: [String: Any])
    case conversationDeleted(data: [String: Any])
    case connectionStateChanged(connected: Bool)
    case reconnected
}

/// Result of a reconnect attempt
enum ReconnectResult {
    case success
    case authFailed    // Keys invalid (e.g. rotated) — should logout immediately
    case networkError  // TCP/connection issue — can retry
}

/// Main chat client — handles all server communication and crypto operations.
/// Thread-safe via Swift actor isolation.
/// Port of Python ChatClient class from chat_core.py
actor ChatClient {

    // MARK: - Connection

    let connectionManager = ConnectionManager()
    private(set) var isConnected = false
    private var lastHost = Constants.defaultHost
    private var lastPort = Constants.defaultPort
    private(set) var sessionToken: String?
    private(set) var userId: String?
    private(set) var username: String = ""
    private(set) var email: String = ""
    private(set) var loginRejected = false
    private(set) var onlineUserIds: Set<String> = []

    // MARK: - Keys

    private var rsaPrivate: SecKey?
    private var rsaPublic: SecKey?
    private(set) var identityPrivate: Curve25519.Signing.PrivateKey?
    private(set) var identityPublic: Curve25519.Signing.PublicKey?
    private var spkPrivate: Curve25519.KeyAgreement.PrivateKey?
    private var spkId: String = ""
    private var prevSpkPrivate: Curve25519.KeyAgreement.PrivateKey?
    private var prevSpkId: String = ""
    private var opkPrivates: [String: Curve25519.KeyAgreement.PrivateKey] = [:]

    // MARK: - Sessions & Sender Keys

    private var sessions: [String: DoubleRatchet] = [:]         // "userId:deviceId" -> ratchet
    private var senderKeyStates: [String: SenderKeyState] = [:] // convId -> own sender key
    private var recvSenderKeys: [String: SenderKeyState] = [:]  // "convId:senderId:deviceId" -> their key

    // MARK: - Derived Keys

    private(set) var cacheKey: Data?   // for encrypting message cache
    private(set) var localKey: Data?   // for encrypting session/sender key files

    // MARK: - Multi-Device

    private(set) var deviceId: String?

    // MARK: - Caches

    private var userCache: [String: User] = [:]
    private var deviceBundleCache: [String: (timestamp: Date, bundles: [DeviceBundle])] = [:]

    // MARK: - Self-Encrypt Queue

    private var pendingSelfEncrypt: [(messageId: String, plaintext: Data)] = []

    // MARK: - TOFU / Contact Verification

    private var knownIdentityKeys: [String: [String: String]] = [:]  // userId -> {identity_key, first_seen, last_seen}
    private var verifiedContacts: [String: [String: String]] = [:]   // userId -> {identity_key, verified_at, method}

    // MARK: - Brute-Force Lockout

    private static let lockoutBaseSeconds: TimeInterval = 2
    private static let lockoutMaxSeconds: TimeInterval = 300

    // MARK: - Request/Response Tracking

    private var pendingRequests: [String: CheckedContinuation<[String: Any], Error>] = [:]
    private var listenerTask: Task<Void, Never>?

    // MARK: - Notification Broadcast (supports multiple consumers)

    private var notificationSubscribers: [UUID: AsyncStream<ChatNotification>.Continuation] = [:]

    /// Create a new notification stream. Each subscriber gets ALL notifications.
    func makeNotificationStream() -> AsyncStream<ChatNotification> {
        let id = UUID()
        var captured: AsyncStream<ChatNotification>.Continuation!
        let stream = AsyncStream<ChatNotification> { cont in
            captured = cont
        }
        captured.onTermination = { [weak self] _ in
            Task { await self?.removeSubscriber(id: id) }
        }
        notificationSubscribers[id] = captured
        return stream
    }

    private func removeSubscriber(id: UUID) {
        notificationSubscribers.removeValue(forKey: id)
    }

    private func broadcastNotification(_ notification: ChatNotification) {
        for (_, continuation) in notificationSubscribers {
            continuation.yield(notification)
        }
    }

    // MARK: - Init

    init() {}

    // MARK: - Connection

    func connect(host: String = Constants.defaultHost, port: UInt16 = Constants.defaultPort) async throws {
        try await connectionManager.connect(host: host, port: port)
        lastHost = host
        lastPort = port
        isConnected = true
        startBackgroundListener()
        broadcastNotification(.connectionStateChanged(connected: true))
    }

    /// Check if the underlying TCP connection is actually alive
    func isConnectionAlive() async -> Bool {
        guard isConnected else { return false }
        return await connectionManager.isConnected
    }

    func disconnect() async {
        listenerTask?.cancel()
        listenerTask = nil
        await connectionManager.disconnect()
        isConnected = false
        // Fail all pending requests
        let pending = pendingRequests
        pendingRequests.removeAll()
        for (_, cont) in pending {
            cont.resume(throwing: NetworkError.notConnected)
        }
        broadcastNotification(.connectionStateChanged(connected: false))
    }

    // MARK: - Send and Receive

    /// Send a request and wait for the matching response.
    func sendAndReceive(type: String, timeout: TimeInterval = 30, params: [String: Any] = [:]) async -> [String: Any] {
        let requestId = ProtocolHandler.newRequestId()
        #if DEBUG
        print("DEBUG sendAndReceive: type=\(type), requestId=\(requestId)")
        #endif

        // Timeout task — resumes continuation with error if server doesn't respond
        let timeoutTask = Task {
            try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
            if let cont = pendingRequests.removeValue(forKey: requestId) {
                cont.resume(throwing: NetworkError.timeout)
            }
        }

        do {
            let response: [String: Any] = try await withCheckedThrowingContinuation { continuation in
                pendingRequests[requestId] = continuation

                Task {
                    do {
                        try await connectionManager.sendMessage(type: type, requestId: requestId, params: params)
                    } catch {
                        if let cont = pendingRequests.removeValue(forKey: requestId) {
                            cont.resume(throwing: error)
                        }
                    }
                }
            }
            timeoutTask.cancel()
            return response
        } catch {
            timeoutTask.cancel()
            pendingRequests.removeValue(forKey: requestId)
            return [
                "type": type,
                "status": "error",
                "data": ["message": error.localizedDescription]
            ]
        }
    }

    // MARK: - Background Listener

    func startBackgroundListener() {
        listenerTask?.cancel()
        listenerTask = Task { [weak self] in
            guard let self = self else { return }
            await self.backgroundListenerLoop()
        }
    }

    private func backgroundListenerLoop() async {
        while !Task.isCancelled {
            do {
                guard let msg = try await connectionManager.readMessage() else {
                    // EOF — connection closed
                    handleDisconnect()
                    break
                }
                routeMessage(msg)
            } catch {
                handleDisconnect()
                break
            }
        }
    }

    private func handleDisconnect() {
        isConnected = false
        // Fail all pending futures
        let pending = pendingRequests
        pendingRequests.removeAll()
        for (_, cont) in pending {
            cont.resume(throwing: NetworkError.notConnected)
        }
        broadcastNotification(.connectionStateChanged(connected: false))
    }

    private func routeMessage(_ msg: [String: Any]) {
        #if DEBUG
        print("DEBUG routeMessage received: \(msg)")
        #endif
        let msgType = msg["type"] as? String ?? ""

        // Notification types (no request_id expected from client)
        let notificationTypes = Set([
            "new_message", "messages_read", "message_deleted",
            "conversation_created", "member_added", "member_removed",
            "user_online", "user_offline", "online_users",
            "group_invitation", "conversation_renamed", "session_reset",
            "keys_updated", "message_reacted", "message_pinned", "message_unpinned",
            "message_delivered", "conversation_deleted"
        ])

        if notificationTypes.contains(msgType) {
            let data = msg["data"] as? [String: Any] ?? msg
            switch msgType {
            case "new_message":
                broadcastNotification(.newMessage(data: data))
            case "messages_read":
                broadcastNotification(.messagesRead(data: data))
            case "message_deleted":
                broadcastNotification(.messageDeleted(data: data))
            case "conversation_created":
                broadcastNotification(.conversationCreated(data: data))
            case "member_added":
                broadcastNotification(.memberAdded(data: data))
            case "member_removed":
                broadcastNotification(.memberRemoved(data: data))
            case "user_online":
                if let uid = data["user_id"] as? String {
                    onlineUserIds.insert(uid)
                    broadcastNotification(.userOnline(userId: uid))
                }
            case "user_offline":
                if let uid = data["user_id"] as? String {
                    onlineUserIds.remove(uid)
                    broadcastNotification(.userOffline(userId: uid))
                }
            case "online_users":
                if let uids = data["user_ids"] as? [String] {
                    onlineUserIds = Set(uids)
                    broadcastNotification(.onlineUsers(userIds: uids))
                }
            case "group_invitation":
                broadcastNotification(.groupInvitation(data: data))
            case "conversation_renamed":
                broadcastNotification(.conversationRenamed(data: data))
            case "session_reset":
                broadcastNotification(.sessionReset(data: data))
            case "message_reacted":
                broadcastNotification(.messageReacted(data: data))
            case "message_pinned":
                broadcastNotification(.messagePinned(data: data))
            case "message_unpinned":
                broadcastNotification(.messageUnpinned(data: data))
            case "message_delivered":
                broadcastNotification(.messageDelivered(data: data))
            case "conversation_deleted":
                broadcastNotification(.conversationDeleted(data: data))
            case "keys_updated":
                // Peer uploaded new prekeys (new device or rotation) — invalidate bundle cache
                if let uid = data["user_id"] as? String {
                    deviceBundleCache.removeValue(forKey: uid)
                    #if DEBUG
                    print("DEBUG keys_updated: invalidated bundle cache for \(uid)")
                    #endif
                }
            default:
                break
            }
        } else {
            // Response to a pending request
            if let requestId = msg["request_id"] as? String,
               let cont = pendingRequests.removeValue(forKey: requestId) {
                cont.resume(returning: msg)
            }
        }
    }

    // MARK: - User Info Cache

    func getUserInfo(userId: String = "", userEmail: String = "") async -> User? {
        if !userId.isEmpty, let cached = userCache[userId] {
            return cached
        }
        var params: [String: Any] = [:]
        if !userId.isEmpty { params["user_id"] = userId }
        else if !userEmail.isEmpty { params["email"] = userEmail }
        else { return nil }

        let resp = await sendAndReceive(type: "get_user_info", params: params)
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data") else { return nil }

        var ikData: Data?
        if let ikB64 = data["identity_key"] as? String {
            ikData = try? ProtocolHandler.decodeBinary(ikB64)
        }

        let user = User(
            id: data.string(for: "user_id") ?? "",
            username: data.string(for: "username") ?? "",
            email: data.string(for: "email") ?? "",
            identityKey: ikData
        )
        userCache[user.id] = user
        return user
    }

    // MARK: - Registration

    func register(username: String, password: String, email: String) async -> (success: Bool, message: String) {
        self.username = username
        self.email = email
        var pwdBytes = Array(password.utf8)
        defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } }

        let pwdData = Data(pwdBytes)

        do {
            // RSA keys
            let (rsaPriv, rsaPub, _) = KeyStorage.loadRSAKeys(email: email, password: pwdData)
            if let rsaPriv = rsaPriv, let rsaPub = rsaPub {
                self.rsaPrivate = rsaPriv
                self.rsaPublic = rsaPub
            } else {
                let (newPriv, newPub) = try RSACrypto.generateKeypair()
                try KeyStorage.saveRSAKeys(email: email, privateKey: newPriv, publicKey: newPub, password: pwdData)
                self.rsaPrivate = newPriv
                self.rsaPublic = newPub
            }

            // Ed25519 identity keys
            let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: pwdData)
            if let edPriv = edPriv, let edPub = edPub {
                self.identityPrivate = edPriv
                self.identityPublic = edPub
            } else {
                let (newPriv, newPub) = Ed25519Crypto.generateKeypair()
                try KeyStorage.saveIdentityKeys(email: email, privateKey: newPriv, publicKey: newPub, password: pwdData)
                self.identityPrivate = newPriv
                self.identityPublic = newPub
            }

            self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate!.rawData)
            self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: identityPrivate!.rawData)
        } catch {
            return (false, "Key generation failed: \(error.localizedDescription)")
        }

        // Send registration request
        let pubPem = String(data: try! RSACrypto.serializePublicKey(rsaPublic!), encoding: .utf8)!
        let ikB64 = ProtocolHandler.encodeBinary(Ed25519Crypto.serializePublic(identityPublic!))

        let resp = await sendAndReceive(type: "register", params: [
            "username": username,
            "public_key": pubPem,
            "email": email,
            "identity_key": ikB64,
        ])

        #if DEBUG
        print("DEBUG register response: \(resp)")
        #endif

        // Handle PoW challenge if required by server
        if resp.string(for: "status") == "pow_required",
           let powData = resp.dict(for: "data"),
           let powChallenge = powData.string(for: "challenge"),
           let powMac = powData.string(for: "mac"),
           let powDifficulty = powData.int(for: "difficulty") {
            #if DEBUG
            print("DEBUG register: PoW required, difficulty=\(powDifficulty)")
            #endif
            let powNonce = ChatClient.solvePow(challenge: powChallenge, difficulty: powDifficulty)
            #if DEBUG
            print("DEBUG register: PoW solved, nonce=\(powNonce)")
            #endif

            // Retry with PoW solution
            let retryResp = await sendAndReceive(type: "register", params: [
                "username": username,
                "public_key": pubPem,
                "email": email,
                "identity_key": ikB64,
                "pow_challenge": powChallenge,
                "pow_mac": powMac,
                "pow_nonce": powNonce,
            ])

            guard retryResp.string(for: "status") == "ok" else {
                let msg = retryResp.dict(for: "data")?.string(for: "message") ?? "Registration failed after PoW"
                return (false, msg)
            }
            let retryData = retryResp.dict(for: "data") ?? [:]
            if let code = retryData.string(for: "code") {
                return (true, code)
            }
            return (true, retryData.string(for: "message") ?? "Check your email for the code.")
        }

        guard resp.string(for: "status") == "ok" else {
            let msg = resp.dict(for: "data")?.string(for: "message") ?? "Registration failed"
            return (false, msg)
        }

        let data = resp.dict(for: "data") ?? [:]
        if let code = data.string(for: "code") {
            return (true, code)
        }
        return (true, data.string(for: "message") ?? "Check your email for the code.")
    }

    func confirmRegistration(email: String, username: String, code: String) async -> (success: Bool, message: String) {
        #if DEBUG
        print("DEBUG confirmRegistration: email=\(email), code=\(code)")
        #endif
        let resp = await sendAndReceive(type: "register_confirm", params: [
            "email": email,
            "code": code,
        ])

        guard resp.string(for: "status") == "ok" else {
            let msg = resp.dict(for: "data")?.string(for: "message") ?? "Confirmation failed"
            return (false, msg)
        }

        // NOTE: Don't upload prekeys here - user isn't logged in yet.
        // ensurePrekeys() will be called after login.

        let uid = resp.dict(for: "data")?.string(for: "user_id") ?? ""
        return (true, "Registered as '\(username)' (ID: \(uid))")
    }

    // MARK: - Prekeys

    private func generateAndUploadPrekeys(keepSPK: Bool = false) async {
        guard let identityPrivate = identityPrivate else { return }

        do {
            let spkData: [String: Any]

            if keepSPK, let spkPriv = spkPrivate, !spkId.isEmpty {
                let spkPubBytes = X25519Crypto.serializePublic(spkPriv.publicKey)
                let sig = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes)
                spkData = [
                    "id": spkId,
                    "public_key": ProtocolHandler.encodeBinary(spkPubBytes),
                    "signature": ProtocolHandler.encodeBinary(sig),
                ]
            } else {
                // Save current as previous (grace period)
                if let spkPriv = spkPrivate, !spkId.isEmpty {
                    prevSpkPrivate = spkPriv
                    prevSpkId = spkId
                    try? KeyStorage.savePrevSPK(email: email, privateKey: spkPriv, spkId: spkId)
                }

                let spk = try X3DH.generateSignedPrekey(identityPrivate: identityPrivate)
                self.spkPrivate = spk.privateKey
                self.spkId = spk.id
                try? KeyStorage.saveSPK(email: email, privateKey: spk.privateKey, spkId: spk.id)

                spkData = [
                    "id": spk.id,
                    "public_key": ProtocolHandler.encodeBinary(X25519Crypto.serializePublic(spk.publicKey)),
                    "signature": ProtocolHandler.encodeBinary(spk.signature),
                ]
            }

            // Generate OPKs
            let opks = X3DH.generateOneTimePrekeys(count: Constants.opkBatchSize)
            #if DEBUG
            print("DEBUG generatePrekeys: generating \(opks.count) OPKs")
            #endif
            for opk in opks {
                opkPrivates[opk.id] = opk.privateKey
                do {
                    try KeyStorage.saveOPKPrivate(email: email, opkId: opk.id, privateKey: opk.privateKey)
                } catch {
                    #if DEBUG
                    print("DEBUG generatePrekeys: FAILED to save OPK \(opk.id): \(error)")
                    #endif
                }
            }
            #if DEBUG
            print("DEBUG generatePrekeys: OPK IDs = \(opks.map { $0.id })")
            #endif

            let otpData = opks.map { opk -> [String: Any] in
                [
                    "id": opk.id,
                    "public_key": ProtocolHandler.encodeBinary(X25519Crypto.serializePublic(opk.publicKey)),
                ]
            }

            let uploadResp = await sendAndReceive(type: "ensure_prekeys", params: [
                "signed_prekey": spkData,
                "one_time_prekeys": otpData,
            ])
            let uploadStatus = uploadResp.string(for: "status") ?? "nil"
            #if DEBUG
            print("DEBUG generatePrekeys: ensure_prekeys response status=\(uploadStatus) deviceId=\(deviceId ?? "nil")")
            #endif
            if uploadStatus != "ok" {
                let errMsg = uploadResp.dict(for: "data")?.string(for: "message") ?? "unknown"
                #if DEBUG
                print("DEBUG generatePrekeys: upload FAILED: \(errMsg)")
                #endif
            }
        } catch {
            // Log error but don't fail
            #if DEBUG
            print("Prekey generation error: \(error)")
            #endif
        }
    }

    private func ensurePrekeys() async {
        let resp = await sendAndReceive(type: "get_prekey_count")
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data") else { return }

        let count = data.int(for: "count") ?? 0
        let spkCreatedAt = data.string(for: "spk_created_at") ?? ""

        var needNewSPK = false
        if !spkCreatedAt.isEmpty {
            if let created = DateParsing.parse(spkCreatedAt) {
                let ageDays = Calendar.current.dateComponents([.day], from: created, to: Date()).day ?? 0
                if ageDays >= Constants.spkRotationDays {
                    needNewSPK = true
                }
            }
        }

        if count < Constants.opkReplenishThreshold || needNewSPK {
            await generateAndUploadPrekeys()
        }
    }

    // MARK: - Login

    func login(email: String, password: String) async -> (success: Bool, message: String) {
        self.email = email

        // Check brute-force lockout
        let lockoutRemaining = ChatClient.checkLockout(email: email)
        if lockoutRemaining > 0 {
            return (false, "Too many failed attempts. Wait \(Int(lockoutRemaining))s.")
        }

        var pwdBytes = Array(password.utf8)
        defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } }
        let pwdData = Data(pwdBytes)

        // Load RSA keys
        let (rsaPriv, rsaPub, err) = KeyStorage.loadRSAKeys(email: email, password: pwdData)
        guard let rsaPriv = rsaPriv, let rsaPub = rsaPub else {
            return (false, err ?? "No local keys found. Register first.")
        }
        self.rsaPrivate = rsaPriv
        self.rsaPublic = rsaPub

        // Load identity keys
        let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: pwdData)
        if let edPriv = edPriv, let edPub = edPub {
            self.identityPrivate = edPriv
            self.identityPublic = edPub
            self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData)
            self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: edPriv.rawData)
        }

        // Load SPK
        let (spkP, spkI) = KeyStorage.loadSPK(email: email)
        if let spkP = spkP {
            self.spkPrivate = spkP
            self.spkId = spkI ?? ""
        }

        // Load previous SPK (grace period)
        let (prevP, prevI) = KeyStorage.loadPrevSPK(email: email)
        if let prevP = prevP {
            self.prevSpkPrivate = prevP
            self.prevSpkId = prevI ?? ""
        }

        // Load device ID
        self.deviceId = KeyStorage.loadDeviceId(email: email)

        // RSA challenge-response login
        let startResp = await sendAndReceive(type: "login_start", params: ["email": email])
        guard startResp.string(for: "status") == "ok",
              let startData = startResp.dict(for: "data"),
              let challengeB64 = startData.string(for: "challenge") else {
            let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Login failed"
            return (false, msg)
        }

        let challengeData: Data
        do {
            challengeData = try ProtocolHandler.decodeBinary(challengeB64)
        } catch {
            return (false, "Invalid challenge data")
        }

        let signature: Data
        do {
            signature = try RSACrypto.sign(rsaPriv, data: challengeData)
        } catch {
            return (false, "RSA signing failed: \(error.localizedDescription)")
        }

        var finishParams: [String: Any] = [
            "email": email,
            "signature": ProtocolHandler.encodeBinary(signature),
            "client_version": Constants.version,
        ]
        if let deviceId = deviceId {
            finishParams["device_id"] = deviceId
        }

        let finishResp = await sendAndReceive(type: "login_finish", params: finishParams)
        guard finishResp.string(for: "status") == "ok",
              let finishData = finishResp.dict(for: "data") else {
            let msg = finishResp.dict(for: "data")?.string(for: "message") ?? "Login failed"
            loginRejected = true
            ChatClient.recordFailedAttempt(email: email)
            return (false, msg)
        }

        self.userId = finishData.string(for: "user_id")
        self.username = finishData.string(for: "username") ?? ""
        self.sessionToken = finishData.string(for: "session_token")

        // Save device ID from server
        if let newDeviceId = finishData.string(for: "device_id") {
            self.deviceId = newDeviceId
            try? KeyStorage.saveDeviceId(email: email, deviceId: newDeviceId)
        }

        // Handle online_users if included
        if let onlineUserIds = finishData["online_user_ids"] as? [String] {
            self.onlineUserIds = Set(onlineUserIds)
            broadcastNotification(.onlineUsers(userIds: onlineUserIds))
        }

        // Check if we have local OPK private keys.
        // After device pairing, the new device has no local OPKs — must generate fresh ones.
        let hasLocalOPKs: Bool
        if let dir = try? KeyStorage.getKeyDir(email: email) {
            let opkDir = dir.appendingPathComponent("opk_private")
            let files = (try? FileManager.default.contentsOfDirectory(atPath: opkDir.path)) ?? []
            hasLocalOPKs = !files.isEmpty
        } else {
            hasLocalOPKs = false
        }

        if hasLocalOPKs {
            // Existing device — check/replenish prekeys in background
            Task { await ensurePrekeys() }
        } else {
            // New device — MUST upload prekeys before returning so other clients
            // can encrypt for this device immediately. Fire-and-forget would create
            // a race where senders fetch bundles before prekeys exist on server.
            #if DEBUG
            print("DEBUG login: no local OPKs (likely new device). Generating fresh prekeys (synchronous).")
            #endif
            await generateAndUploadPrekeys(keepSPK: true)
        }

        // Load previous SPK for grace period (M4)
        let (prevSPK, prevSPKId) = KeyStorage.loadPrevSPK(email: email)
        if let prevSPK = prevSPK {
            prevSpkPrivate = prevSPK
            prevSpkId = prevSPKId ?? ""
        }

        // Load TOFU and verification stores
        loadVerificationStores()

        // Clear lockout on successful login
        ChatClient.clearLockout(email: email)

        return (true, "Logged in as \(username)")
    }

    // MARK: - Reconnect

    func reconnect() async -> ReconnectResult {
        guard rsaPrivate != nil else { return .authFailed }

        let host = lastHost
        let port = lastPort

        await disconnect()

        do {
            try await connect(host: host, port: port)
        } catch {
            return .networkError
        }

        // TCP connected — try RSA challenge-response with in-memory keys
        let startResp = await sendAndReceive(type: "login_start", params: ["email": email])
        guard startResp.string(for: "status") == "ok",
              let startData = startResp.dict(for: "data"),
              let challengeB64 = startData.string(for: "challenge"),
              let challengeData = try? ProtocolHandler.decodeBinary(challengeB64),
              let signature = try? RSACrypto.sign(rsaPrivate!, data: challengeData) else {
            // TCP was fine but login_start failed — auth issue
            return .authFailed
        }

        var finishParams: [String: Any] = [
            "email": email,
            "signature": ProtocolHandler.encodeBinary(signature),
            "client_version": Constants.version,
        ]
        if let deviceId = deviceId {
            finishParams["device_id"] = deviceId
        }

        let finishResp = await sendAndReceive(type: "login_finish", params: finishParams)
        guard finishResp.string(for: "status") == "ok",
              let finishData = finishResp.dict(for: "data") else {
            // TCP connected, got challenge, but signature rejected — keys rotated
            return .authFailed
        }

        // Update session token if server returned a new one
        if let token = finishData.string(for: "session_token") {
            self.sessionToken = token
        }

        // Refresh online users (matches Python: self.session = finish["data"])
        if let onlineUserIds = finishData["online_user_ids"] as? [String] {
            self.onlineUserIds = Set(onlineUserIds)
            broadcastNotification(.onlineUsers(userIds: onlineUserIds))
        }

        // Replenish prekeys if needed (matches Python: asyncio.create_task(self._ensure_prekeys()))
        Task { await ensurePrekeys() }

        broadcastNotification(.reconnected)
        return .success
    }

    // MARK: - Device Bundles

    private func getDeviceBundles(userId: String) async throws -> [DeviceBundle] {
        // Check cache (5-min TTL)
        if let cached = deviceBundleCache[userId],
           Date().timeIntervalSince(cached.timestamp) < Constants.deviceBundleCacheTTL {
            return cached.bundles
        }

        let resp = await sendAndReceive(type: "get_key_bundle", params: ["user_id": userId])
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data") else {
            throw ChatError.operationFailed("Failed to get key bundle")
        }

        var bundles: [DeviceBundle] = []

        // Get identity key from top level (shared across all device bundles)
        var identityKey: Data?
        if let ikB64 = data["identity_key"] as? String {
            identityKey = Data(base64Encoded: ikB64)
        }

        // Per-device bundles (new format)
        if let deviceBundlesRaw = data["device_bundles"] as? [[String: Any]] {
            for bundleDict in deviceBundlesRaw {
                if let bundle = try? DeviceBundle.fromDict(bundleDict, identityKey: identityKey) {
                    bundles.append(bundle)
                }
            }
        }
        // Legacy single bundle
        else if identityKey != nil {
            let bundle = try DeviceBundle.fromDict(data, identityKey: identityKey)
            bundles.append(bundle)
        }

        deviceBundleCache[userId] = (Date(), bundles)
        return bundles
    }

    // MARK: - Session Management

    private func getOrCreateSession(
        peerUserId: String,
        peerDeviceId: String,
        bundle: DeviceBundle
    ) async throws -> DoubleRatchet {
        let sessionKey = "\(peerUserId):\(peerDeviceId)"
        #if DEBUG
        print("DEBUG getOrCreateSession: looking for session \(sessionKey)")
        #endif

        // Check memory
        if let session = sessions[sessionKey] {
            #if DEBUG
            print("DEBUG getOrCreateSession: found in memory")
            #endif
            return session
        }

        // Check disk
        if let session = KeyStorage.loadSession(
            email: email,
            peerUserId: peerUserId,
            localKey: localKey,
            peerDeviceId: peerDeviceId
        ) {
            #if DEBUG
            print("DEBUG getOrCreateSession: found on disk")
            #endif
            sessions[sessionKey] = session
            return session
        }

        #if DEBUG
        print("DEBUG getOrCreateSession: creating new session via X3DH")
        #endif

        // Create new via X3DH
        let remoteIkEd = try Ed25519Crypto.loadPublic(bundle.identityKey)
        let spkRemote = try X25519Crypto.loadPublic(bundle.spk)
        var opkRemote: Curve25519.KeyAgreement.PublicKey?
        if let opkData = bundle.opk {
            opkRemote = try X25519Crypto.loadPublic(opkData)
        }

        let (sharedSecret, _, ekPub) = try X3DH.initiate(
            ikPrivateEd: identityPrivate!,
            ikPublicRemoteEd: remoteIkEd,
            spkRemote: spkRemote,
            spkSignature: bundle.spkSignature,
            opkRemote: opkRemote
        )

        let ratchet = try DoubleRatchet.initAlice(sharedSecret: sharedSecret, bobSpkPub: spkRemote)

        // Build X3DH header for first message
        // Keys must be base64 encoded (not hex) to match Python server
        let myIkBytes = Ed25519Crypto.serializePublic(identityPublic!)
        let ekPubBytes = X25519Crypto.serializePublic(ekPub)
        #if DEBUG
        print("DEBUG getOrCreateSession: sending ik len=\(myIkBytes.count)")
        print("DEBUG getOrCreateSession: sending ek len=\(ekPubBytes.count)")
        #endif
        var x3dhHeader: [String: Any] = [
            "ik": ProtocolHandler.encodeBinary(myIkBytes),
            "ek": ProtocolHandler.encodeBinary(ekPubBytes),
            "spk_id": bundle.spkId,
        ]
        if let opkId = bundle.opkId {
            x3dhHeader["opk_id"] = opkId
            #if DEBUG
            print("DEBUG getOrCreateSession: using opk_id = \(opkId)")
            #endif
        }
        ratchet.x3dhHeader = x3dhHeader

        sessions[sessionKey] = ratchet
        try? KeyStorage.saveSession(email: email, peerUserId: peerUserId, ratchet: ratchet, localKey: localKey, peerDeviceId: peerDeviceId)

        return ratchet
    }

    // MARK: - X3DH Response (Bob Side)

    private func processX3DHHeader(
        senderId: String,
        x3dhHeader: [String: Any],
        senderDeviceId: String,
        spkOverride: Curve25519.KeyAgreement.PrivateKey? = nil
    ) throws -> DoubleRatchet {
        guard let ikB64 = x3dhHeader["ik"] as? String,
              let ikData = try? ProtocolHandler.decodeBinary(ikB64),
              let ekB64 = x3dhHeader["ek"] as? String,
              let ekData = try? ProtocolHandler.decodeBinary(ekB64) else {
            throw CryptoError.x3dhFailed("Invalid X3DH header - missing ik or ek")
        }

        // spk_id is optional - if missing, use current SPK
        let spkIdStr = x3dhHeader["spk_id"] as? String
        #if DEBUG
        print("DEBUG processX3DHHeader: ik=\(ikData.count)B, ek=\(ekData.count)B, spk_id=\(spkIdStr ?? "nil")")
        #endif

        let remoteIkEd = try Ed25519Crypto.loadPublic(ikData)
        let ekRemote = try X25519Crypto.loadPublic(ekData)

        // Determine which SPK to use
        let spkToUse: Curve25519.KeyAgreement.PrivateKey
        if let override = spkOverride {
            #if DEBUG
            print("DEBUG processX3DHHeader: using spkOverride")
            #endif
            spkToUse = override
        } else if let spkIdStr = spkIdStr {
            // spk_id provided - match against known SPKs
            #if DEBUG
            print("DEBUG processX3DHHeader: spk_id provided, mySpkId=\(spkId), prevSpkId=\(prevSpkId)")
            #endif
            if spkIdStr == spkId, let spk = spkPrivate {
                spkToUse = spk
            } else if spkIdStr == prevSpkId, let prevSpk = prevSpkPrivate {
                spkToUse = prevSpk
            } else {
                throw CryptoError.x3dhFailed("SPK \(spkIdStr) not found")
            }
        } else {
            // No spk_id provided - use current SPK (for backward compatibility)
            #if DEBUG
            print("DEBUG processX3DHHeader: no spk_id, using current SPK")
            #endif
            guard let spk = spkPrivate else {
                throw CryptoError.x3dhFailed("No SPK available")
            }
            spkToUse = spk
        }

        // OPK
        var opkPriv: Curve25519.KeyAgreement.PrivateKey?
        if let opkIdStr = x3dhHeader["opk_id"] as? String {
            #if DEBUG
            print("DEBUG processX3DHHeader: looking for OPK \(opkIdStr)")
            print("DEBUG processX3DHHeader: opkPrivates has \(opkPrivates.count) keys: \(Array(opkPrivates.keys).prefix(5))...")
            #endif

            // Check file system
            if let dir = try? KeyStorage.getKeyDir(email: email) {
                let opkDir = dir.appendingPathComponent("opk_private")
                let opkFile = opkDir.appendingPathComponent("\(opkIdStr).bin")
                #if DEBUG
                print("DEBUG processX3DHHeader: checking file \(opkFile.path)")
                print("DEBUG processX3DHHeader: file exists = \(FileManager.default.fileExists(atPath: opkFile.path))")
                #endif
                if let files = try? FileManager.default.contentsOfDirectory(atPath: opkDir.path) {
                    #if DEBUG
                    print("DEBUG processX3DHHeader: opk_private dir has \(files.count) files: \(files.prefix(5))...")
                    #endif
                }
            }

            opkPriv = opkPrivates[opkIdStr] ?? KeyStorage.loadOPKPrivate(email: email, opkId: opkIdStr)
            if opkPriv != nil {
                #if DEBUG
                print("DEBUG processX3DHHeader: OPK found")
                #endif
                opkPrivates.removeValue(forKey: opkIdStr)
                KeyStorage.deleteOPKPrivate(email: email, opkId: opkIdStr)
            } else {
                #if DEBUG
                print("DEBUG processX3DHHeader: OPK NOT found - continuing without OPK")
                #endif
            }
        }

        #if DEBUG
        print("DEBUG processX3DHHeader: performing X3DH respond")
        #endif
        let sharedSecret = try X3DH.respond(
            ikPrivateEd: identityPrivate!,
            spkPrivate: spkToUse,
            ikRemoteEd: remoteIkEd,
            ekRemote: ekRemote,
            opkPrivate: opkPriv
        )
        #if DEBUG
        print("DEBUG processX3DHHeader: X3DH respond success, sharedSecret=\(sharedSecret.count) bytes")
        #endif

        let ratchet = DoubleRatchet.initBob(
            sharedSecret: sharedSecret,
            spkPair: (spkToUse, spkToUse.publicKey)
        )

        let sessionKey = "\(senderId):\(senderDeviceId)"
        sessions[sessionKey] = ratchet
        try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId)
        #if DEBUG
        print("DEBUG processX3DHHeader: session created for \(sessionKey)")
        #endif

        return ratchet
    }

    // MARK: - Send Message

    func sendMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil,
                     extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) {
        let isGroup = members.count > 2

        if isGroup {
            return await sendGroupMessage(convId: convId, text: text, members: members, replyTo: replyTo, extraPayload: extraPayload, imageFileId: imageFileId)
        } else {
            return await sendDM(convId: convId, text: text, members: members, replyTo: replyTo, extraPayload: extraPayload, imageFileId: imageFileId)
        }
    }

    // MARK: - Send DM

    private func sendDM(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil,
                        extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) {
        #if DEBUG
        print("DEBUG sendDM: convId=\(convId), members=\(members.map { $0.userId })")
        #endif
        guard let identityPrivate = identityPrivate else {
            #if DEBUG
            print("DEBUG sendDM: Identity key not loaded")
            #endif
            return (false, "Identity key not loaded", nil)
        }

        // Build JSON payload matching Python format for cross-client compatibility
        var payload: [String: Any] = [
            "sender": username,
            "text": text,
            "reply_to": (replyTo as Any?) ?? NSNull(),
            "timestamp": ISO8601DateFormatter().string(from: Date()),
        ]
        // Merge extra fields (image, file) at top level
        if let extra = extraPayload {
            for (key, value) in extra { payload[key] = value }
        }
        let rawPlaintext: Data
        if let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) {
            rawPlaintext = jsonData
        } else {
            rawPlaintext = Data(text.utf8)
        }
        let plaintext = MessagePadding.pad(rawPlaintext)

        var recipients: [[String: Any]] = []

        // Ensure we have the other member(s) — refetch if members only contains self
        var actualMembers = members
        let otherMembers = members.filter { $0.userId != userId }
        if otherMembers.isEmpty {
            #if DEBUG
            print("DEBUG sendDM: WARNING — no other members in conversation, refetching from server")
            #endif
            let allConvs = await listConversations()
            if let conv = allConvs.first(where: { $0.id == convId }) {
                actualMembers = conv.members
                #if DEBUG
                print("DEBUG sendDM: refetched \(actualMembers.count) members for conv \(convId)")
                #endif
            }
        }

        // Encrypt for each member's devices
        #if DEBUG
        print("DEBUG sendDM: encrypting for members, my userId=\(userId ?? "nil")")
        #endif
        for member in actualMembers where member.userId != userId {
            #if DEBUG
            print("DEBUG sendDM: processing member \(member.userId)")
            #endif
            do {
                let bundles = try await getDeviceBundles(userId: member.userId)
                #if DEBUG
                print("DEBUG sendDM: got \(bundles.count) device bundles for \(member.userId)")
                #endif
                for (index, bundle) in bundles.enumerated() {
                    #if DEBUG
                    print("DEBUG sendDM: encrypting for bundle \(index + 1)/\(bundles.count), deviceId=\(bundle.deviceId)")
                    #endif
                    let ratchet = try await getOrCreateSession(
                        peerUserId: member.userId,
                        peerDeviceId: bundle.deviceId,
                        bundle: bundle
                    )

                    // Consume X3DH header if present (first message only)
                    let x3dhHeader = ratchet.x3dhHeader
                    ratchet.x3dhHeader = nil

                    let encrypted = try ratchet.encrypt(plaintext)
                    try? KeyStorage.saveSession(email: email, peerUserId: member.userId, ratchet: ratchet, localKey: localKey, peerDeviceId: bundle.deviceId)

                    var recipientEntry: [String: Any] = [
                        "user_id": member.userId,
                        "device_id": bundle.deviceId,
                        "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext),
                        "nonce": ProtocolHandler.encodeBinary(encrypted.nonce),
                        "ratchet_header": encrypted.header,
                    ]
                    if let x3dh = x3dhHeader {
                        recipientEntry["x3dh_header"] = x3dh
                        #if DEBUG
                        print("DEBUG sendDM: x3dh_header for \(bundle.deviceId) = \(x3dh)")
                        #endif
                    }
                    #if DEBUG
                    print("DEBUG sendDM: ratchet_header for \(bundle.deviceId) = \(encrypted.header)")
                    #endif
                    recipients.append(recipientEntry)
                }
            } catch {
                #if DEBUG
                print("DEBUG sendDM: ERROR encrypting for member \(member.userId): \(error)")
                #endif
                return (false, "Encryption failed for \(member.username): \(error.localizedDescription)", nil)
            }
        }

        // Self-encrypted copy (readable by all own devices via shared identity key)
        let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate.rawData)
        if let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) {
            let selfCiphertext = ct + tag
            recipients.append([
                "user_id": userId!,
                "device_id": Constants.selfDeviceId,
                "encrypted_content": ProtocolHandler.encodeBinary(selfCiphertext),
                "nonce": ProtocolHandler.encodeBinary(nonce),
                "ratchet_header": ["self": true],
            ])
        }

        // Build ratchet header for message table (use first recipient's or dummy)
        let ratchetHeader: [String: Any]
        if let first = recipients.first {
            ratchetHeader = first["ratchet_header"] as? [String: Any] ?? [:]
        } else {
            ratchetHeader = ["dh_pub": String(repeating: "00", count: 32), "n": 0, "pn": 0]
        }

        var params: [String: Any] = [
            "conversation_id": convId,
            "ratchet_header": ratchetHeader,
            "recipients": recipients,
        ]
        if let replyTo = replyTo {
            params["reply_to"] = replyTo
        }
        if let imageFileId = imageFileId {
            params["image_file_id"] = imageFileId
        }

        #if DEBUG
        print("DEBUG sendDM: sending with \(recipients.count) recipients")
        #endif
        let resp = await sendAndReceive(type: "send_message", params: params)
        #if DEBUG
        print("DEBUG sendDM: response status=\(resp.string(for: "status") ?? "nil")")
        #endif
        guard resp.string(for: "status") == "ok" else {
            let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed"
            return (false, msg, nil)
        }

        // Build sent Message and cache the plaintext
        var sentMessage: Message?
        if let msgData = resp.dict(for: "data"), let messageId = msgData.string(for: "message_id") {
            // Cache plaintext for future getMessages calls
            MessageCache.cacheDecryptedMessage(
                email: email, convId: convId, messageId: messageId,
                plaintext: plaintext, cacheKey: cacheKey
            )

            var forwardedFrom: ForwardedFrom?
            if let fwd = extraPayload?["forwarded_from"] as? [String: Any],
               let fwdSender = fwd["sender"] as? String {
                forwardedFrom = ForwardedFrom(sender: fwdSender,
                    conversationId: fwd["conversation_id"] as? String ?? "",
                    messageId: fwd["message_id"] as? String ?? "")
            }

            // Parse image/file info from extraPayload so the sent message displays correctly
            var sentImage: ImageInfo?
            if let imgDict = extraPayload?["image"] as? [String: Any],
               let imgFileId = imgDict["file_id"] as? String, !imgFileId.isEmpty {
                sentImage = ImageInfo(
                    fileId: imgFileId,
                    aesKey: imgDict["aes_key"] as? String ?? "",
                    iv: imgDict["iv"] as? String ?? "",
                    thumbnail: imgDict["thumbnail"] as? String,
                    filename: imgDict["filename"] as? String ?? "image.jpg",
                    size: imgDict["size"] as? Int ?? 0
                )
            }
            var sentFile: FileInfo?
            if let fileDict = extraPayload?["file"] as? [String: Any],
               let fFileId = fileDict["file_id"] as? String, !fFileId.isEmpty {
                sentFile = FileInfo(
                    fileId: fFileId,
                    aesKey: fileDict["aes_key"] as? String ?? "",
                    iv: fileDict["iv"] as? String ?? "",
                    filename: fileDict["filename"] as? String ?? "",
                    size: fileDict["size"] as? Int ?? 0,
                    mimeType: fileDict["mime_type"] as? String ?? ""
                )
            }

            let createdAt = msgData.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date()
            sentMessage = Message(
                id: messageId, conversationId: convId, senderId: userId!,
                senderUsername: username, createdAt: createdAt,
                text: text.isEmpty ? nil : text, replyTo: replyTo,
                imageFileId: imageFileId, file: sentFile, image: sentImage,
                isDeleted: false, readBy: [],
                reactions: [], forwardedFrom: forwardedFrom, pinnedAt: nil, pinnedBy: nil
            )
        }

        return (true, "Message sent", sentMessage)
    }

    // MARK: - Send Group Message

    private func sendGroupMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil,
                                  extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) {
        guard let identityPrivate = identityPrivate, let userId = userId, deviceId != nil else {
            return (false, "Not properly logged in", nil)
        }

        // Ensure we have all members — refetch if only self is present
        var actualMembers = members
        let otherMembers = members.filter { $0.userId != userId }
        if otherMembers.isEmpty {
            #if DEBUG
            print("DEBUG sendGroupMessage: WARNING — no other members, refetching from server")
            #endif
            let allConvs = await listConversations()
            if let conv = allConvs.first(where: { $0.id == convId }) {
                actualMembers = conv.members
            }
        }

        // Get or create sender key for this group
        var senderKeyState = senderKeyStates[convId]
        if senderKeyState == nil {
            senderKeyState = KeyStorage.loadSenderKeyState(email: email, convId: convId, localKey: localKey)
        }

        var needDistribute = false
        if senderKeyState == nil {
            senderKeyState = SenderKeyState()
            needDistribute = true
        }

        senderKeyStates[convId] = senderKeyState

        // Distribute sender key if new
        if needDistribute {
            await distributeSenderKey(convId: convId, members: actualMembers)
        }

        // Encrypt with sender key — JSON payload for cross-client compatibility
        var groupPayload: [String: Any] = [
            "sender": username,
            "text": text,
            "reply_to": (replyTo as Any?) ?? NSNull(),
            "timestamp": ISO8601DateFormatter().string(from: Date()),
        ]
        if let extra = extraPayload {
            for (key, value) in extra { groupPayload[key] = value }
        }
        let rawPlaintext: Data
        if let jsonData = try? JSONSerialization.data(withJSONObject: groupPayload, options: [.sortedKeys]) {
            rawPlaintext = jsonData
        } else {
            rawPlaintext = Data(text.utf8)
        }
        let plaintext = MessagePadding.pad(rawPlaintext)
        do {
            let encrypted = try senderKeyState!.encrypt(plaintext)
            try? KeyStorage.saveSenderKeyState(email: email, convId: convId, state: senderKeyState!, localKey: localKey)

            // Build recipients (same ciphertext for all)
            var recipients: [[String: Any]] = []
            for member in actualMembers where member.userId != userId {
                recipients.append([
                    "user_id": member.userId,
                    "device_id": Constants.selfDeviceId,  // group messages use sentinel
                    "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext),
                    "nonce": ProtocolHandler.encodeBinary(encrypted.nonce),
                ])
            }

            // Self copy (readable by all own devices via shared identity key)
            let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate.rawData)
            if let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) {
                recipients.append([
                    "user_id": userId,
                    "device_id": Constants.selfDeviceId,
                    "encrypted_content": ProtocolHandler.encodeBinary(ct + tag),
                    "nonce": ProtocolHandler.encodeBinary(nonce),
                    "ratchet_header": ["self": true],
                ])
            }

            let dummyHeader: [String: Any] = [
                "dh_pub": String(repeating: "00", count: 32),
                "n": 0,
                "pn": 0,
            ]

            var params: [String: Any] = [
                "conversation_id": convId,
                "ratchet_header": dummyHeader,
                "recipients": recipients,
            ]

            // Include sender key metadata for group routing (base64-encoded chain ID bytes)
            if let chainIdBytes = Data(hexString: encrypted.chainIdHex) {
                params["sender_chain_id"] = ProtocolHandler.encodeBinary(chainIdBytes)
            }
            params["sender_chain_n"] = encrypted.n

            if let replyTo = replyTo {
                params["reply_to"] = replyTo
            }
            if let imageFileId = imageFileId {
                params["image_file_id"] = imageFileId
            }

            let resp = await sendAndReceive(type: "send_message", params: params)
            guard resp.string(for: "status") == "ok" else {
                let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed"
                return (false, msg, nil)
            }

            // Build sent Message and cache the plaintext
            var sentMessage: Message?
            if let msgData = resp.dict(for: "data"), let messageId = msgData.string(for: "message_id") {
                MessageCache.cacheDecryptedMessage(
                    email: email, convId: convId, messageId: messageId,
                    plaintext: plaintext, cacheKey: cacheKey
                )

                var forwardedFrom: ForwardedFrom?
                if let fwd = extraPayload?["forwarded_from"] as? [String: Any],
                   let fwdSender = fwd["sender"] as? String {
                    forwardedFrom = ForwardedFrom(sender: fwdSender,
                        conversationId: fwd["conversation_id"] as? String ?? "",
                        messageId: fwd["message_id"] as? String ?? "")
                }

                // Parse image/file info from extraPayload so the sent message displays correctly
                var sentImage: ImageInfo?
                if let imgDict = extraPayload?["image"] as? [String: Any],
                   let imgFileId = imgDict["file_id"] as? String, !imgFileId.isEmpty {
                    sentImage = ImageInfo(
                        fileId: imgFileId,
                        aesKey: imgDict["aes_key"] as? String ?? "",
                        iv: imgDict["iv"] as? String ?? "",
                        thumbnail: imgDict["thumbnail"] as? String,
                        filename: imgDict["filename"] as? String ?? "image.jpg",
                        size: imgDict["size"] as? Int ?? 0
                    )
                }
                var sentFile: FileInfo?
                if let fileDict = extraPayload?["file"] as? [String: Any],
                   let fFileId = fileDict["file_id"] as? String, !fFileId.isEmpty {
                    sentFile = FileInfo(
                        fileId: fFileId,
                        aesKey: fileDict["aes_key"] as? String ?? "",
                        iv: fileDict["iv"] as? String ?? "",
                        filename: fileDict["filename"] as? String ?? "",
                        size: fileDict["size"] as? Int ?? 0,
                        mimeType: fileDict["mime_type"] as? String ?? ""
                    )
                }

                let createdAt = msgData.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date()
                sentMessage = Message(
                    id: messageId, conversationId: convId, senderId: userId,
                    senderUsername: username, createdAt: createdAt,
                    text: text.isEmpty ? nil : text, replyTo: replyTo,
                    imageFileId: imageFileId, file: sentFile, image: sentImage,
                    isDeleted: false, readBy: [],
                    reactions: [], forwardedFrom: forwardedFrom, pinnedAt: nil, pinnedBy: nil
                )
            }

            return (true, "Message sent", sentMessage)
        } catch {
            return (false, "Encryption failed: \(error.localizedDescription)", nil)
        }
    }

    // MARK: - Distribute Sender Key

    private func distributeSenderKey(convId: String, members: [ConversationMember]) async {
        guard let senderKeyState = senderKeyStates[convId],
              let userId = userId,
              let deviceId = deviceId else { return }

        let exportedKey = senderKeyState.exportKey()

        for member in members where member.userId != userId {
            do {
                let bundles = try await getDeviceBundles(userId: member.userId)
                for bundle in bundles {
                    let ratchet = try await getOrCreateSession(
                        peerUserId: member.userId,
                        peerDeviceId: bundle.deviceId,
                        bundle: bundle
                    )

                    let x3dhHeader = ratchet.x3dhHeader
                    ratchet.x3dhHeader = nil

                    // Payload includes sender key + metadata
                    let controlPayload: [String: Any] = [
                        "_sender_key": [
                            "conv_id": convId,
                            "key": ProtocolHandler.encodeBinary(exportedKey),
                            "sender_device_id": deviceId,
                        ]
                    ]
                    let controlData = try JSONSerialization.data(withJSONObject: controlPayload)
                    let encrypted = try ratchet.encrypt(controlData)
                    try? KeyStorage.saveSession(email: email, peerUserId: member.userId, ratchet: ratchet, localKey: localKey, peerDeviceId: bundle.deviceId)

                    var recipientEntry: [String: Any] = [
                        "user_id": member.userId,
                        "device_id": bundle.deviceId,
                        "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext),
                        "nonce": ProtocolHandler.encodeBinary(encrypted.nonce),
                        "ratchet_header": encrypted.header,
                    ]
                    if let x3dh = x3dhHeader {
                        recipientEntry["x3dh_header"] = x3dh
                    }

                    let dummyHeader: [String: Any] = [
                        "dh_pub": String(repeating: "00", count: 32),
                        "n": 0,
                        "pn": 0,
                    ]

                    _ = await sendAndReceive(type: "send_message", params: [
                        "conversation_id": convId,
                        "ratchet_header": dummyHeader,
                        "recipients": [recipientEntry],
                    ])
                }
            } catch {
                #if DEBUG
                print("Failed to distribute sender key to \(member.userId): \(error)")
                #endif
            }
        }
    }

    // MARK: - Decrypt

    /// Decrypt DM using Double Ratchet, or static key for self-copies.
    /// Matches Python: _decrypt_dm() lines 1547-1652
    func decryptDMRecipientData(
        senderData: [String: Any],
        senderId: String,
        senderDeviceId: String
    ) -> Data? {
        // Server uses "encrypted_content", fallback to "ciphertext" for compatibility
        let ctB64 = senderData["encrypted_content"] as? String ?? senderData["ciphertext"] as? String
        guard let ctB64 = ctB64,
              let nonceB64 = senderData["nonce"] as? String,
              let ciphertext = try? ProtocolHandler.decodeBinary(ctB64),
              let nonce = try? ProtocolHandler.decodeBinary(nonceB64) else {
            #if DEBUG
            print("DEBUG decryptDM: missing encrypted_content or nonce")
            #endif
            return nil
        }
        #if DEBUG
        print("DEBUG decryptDM: ct=\(ciphertext.count)B nonce=\(nonce.count)B sender=\(senderId) senderDev=\(senderDeviceId)")
        #endif

        let ratchetHeader = senderData["ratchet_header"] as? [String: Any] ?? [:]

        // Self-encrypted message (ratchet_header.self == true)
        // Matches Python lines 1562-1566
        let isSelf = (ratchetHeader["self"] as? Bool) == true || (ratchetHeader["self"] as? Int) == 1
        if isSelf {
            #if DEBUG
            print("DEBUG decryptDM: self-encrypted copy")
            #endif
            guard let cacheKey = cacheKey, ciphertext.count >= 16 else {
                #if DEBUG
                print("DEBUG decryptDM: no cacheKey or ct too short")
                #endif
                return nil
            }
            let ct = ciphertext.prefix(ciphertext.count - 16)
            let tag = ciphertext.suffix(16)
            let result = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: nonce, ciphertext: Data(ct), tag: Data(tag))
            #if DEBUG
            print("DEBUG decryptDM: self-decrypt \(result != nil ? "OK" : "FAILED")")
            #endif
            return result
        }

        // Regular DM — Double Ratchet decryption
        // Matches Python lines 1568-1631
        let x3dhHeader = senderData["x3dh_header"] as? [String: Any]
        let sessionKey = "\(senderId):\(senderDeviceId)"

        // Try to load existing session (memory → disk)
        let ratchet = sessions[sessionKey]
            ?? KeyStorage.loadSession(email: email, peerUserId: senderId, localKey: localKey, peerDeviceId: senderDeviceId)
        if ratchet != nil {
            sessions[sessionKey] = ratchet
        }
        #if DEBUG
        print("DEBUG decryptDM: sessionKey=\(sessionKey) existingSession=\(ratchet != nil) hasX3DH=\(x3dhHeader != nil)")
        #endif

        var plaintext: Data?

        if ratchet != nil && x3dhHeader == nil {
            // Normal case: existing session, no X3DH header (Python line 1581-1585)
            do {
                plaintext = try ratchet!.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce)
                try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet!, localKey: localKey, peerDeviceId: senderDeviceId)
                #if DEBUG
                print("DEBUG decryptDM: normal decrypt OK")
                #endif
            } catch {
                #if DEBUG
                print("DEBUG decryptDM: normal decrypt FAILED: \(error)")
                #endif
                return nil
            }
        } else if let x3dh = x3dhHeader, let existingRatchet = ratchet {
            // Existing session + X3DH: sender may have reset. Try existing first, fallback to new X3DH
            // Matches Python lines 1587-1613
            #if DEBUG
            print("DEBUG decryptDM: existing session + X3DH header, trying existing first")
            #endif
            let backup = try? existingRatchet.exportState()
            do {
                plaintext = try existingRatchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce)
                try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: existingRatchet, localKey: localKey, peerDeviceId: senderDeviceId)
                #if DEBUG
                print("DEBUG decryptDM: existing session decrypt OK (X3DH ignored)")
                #endif
            } catch {
                // Restore from backup and try X3DH
                #if DEBUG
                print("DEBUG decryptDM: existing session failed, trying X3DH")
                #endif
                if let backup = backup, let restored = try? DoubleRatchet.importState(backup) {
                    sessions[sessionKey] = restored
                    try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: restored, localKey: localKey, peerDeviceId: senderDeviceId)
                }
                plaintext = tryX3DHDecrypt(x3dh: x3dh, ratchetHeader: ratchetHeader, ciphertext: ciphertext, nonce: nonce, senderId: senderId, senderDeviceId: senderDeviceId)
            }
        } else if let x3dh = x3dhHeader {
            // No existing session, process X3DH
            // Matches Python lines 1614-1629
            #if DEBUG
            print("DEBUG decryptDM: no existing session, processing X3DH")
            #endif
            plaintext = tryX3DHDecrypt(x3dh: x3dh, ratchetHeader: ratchetHeader, ciphertext: ciphertext, nonce: nonce, senderId: senderId, senderDeviceId: senderDeviceId)
        } else {
            // No session and no X3DH
            #if DEBUG
            print("DEBUG decryptDM: no session and no X3DH for \(senderId)")
            #endif
            return nil
        }

        guard let rawDecrypted = plaintext else { return nil }
        let unpadded = MessagePadding.unpad(rawDecrypted)

        sessions[sessionKey] = ratchet ?? sessions[sessionKey]
        #if DEBUG
        print("DEBUG decryptDM: decrypt OK, \(unpadded.count) bytes")
        #endif

        // Check for sender key distribution (control message)
        if let jsonObj = try? JSONSerialization.jsonObject(with: unpadded) as? [String: Any],
           let senderKeyInfo = jsonObj["_sender_key"] as? [String: Any] {
            handleSenderKeyDistribution(senderKeyInfo, senderId: senderId)
            return nil  // Control message
        }

        return unpadded
    }

    /// Helper: try X3DH header processing + decrypt, with prev SPK fallback.
    /// Matches Python's try/except pattern in _decrypt_dm lines 1615-1629.
    private func tryX3DHDecrypt(
        x3dh: [String: Any], ratchetHeader: [String: Any],
        ciphertext: Data, nonce: Data,
        senderId: String, senderDeviceId: String
    ) -> Data? {
        let sessionKey = "\(senderId):\(senderDeviceId)"
        do {
            let ratchet = try processX3DHHeader(
                senderId: senderId, x3dhHeader: x3dh, senderDeviceId: senderDeviceId)
            let plaintext = try ratchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce)
            sessions[sessionKey] = ratchet
            try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId)
            #if DEBUG
            print("DEBUG tryX3DHDecrypt: OK with current SPK")
            #endif
            return plaintext
        } catch {
            #if DEBUG
            print("DEBUG tryX3DHDecrypt: failed with current SPK: \(error)")
            #endif
            // Try with previous SPK (grace period, M4)
            if let prevSpk = prevSpkPrivate {
                do {
                    let ratchet = try processX3DHHeader(
                        senderId: senderId, x3dhHeader: x3dh, senderDeviceId: senderDeviceId,
                        spkOverride: prevSpk)
                    let plaintext = try ratchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce)
                    sessions[sessionKey] = ratchet
                    try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId)
                    #if DEBUG
                    print("DEBUG tryX3DHDecrypt: OK with prev SPK")
                    #endif
                    return plaintext
                } catch {
                    #if DEBUG
                    print("DEBUG tryX3DHDecrypt: failed with prev SPK too: \(error)")
                    #endif
                }
            }
            return nil
        }
    }

    private func handleSenderKeyDistribution(_ info: [String: Any], senderId: String) {
        guard let convId = info["conv_id"] as? String,
              let keyB64 = info["key"] as? String,
              let keyData = try? ProtocolHandler.decodeBinary(keyB64) else { return }

        let senderDeviceId = info["sender_device_id"] as? String ?? Constants.selfDeviceId

        do {
            let senderKey = try SenderKeyState.fromKey(keyData)
            let stateKey = "\(convId):\(senderId):\(senderDeviceId)"
            recvSenderKeys[stateKey] = senderKey
            try? KeyStorage.saveRecvSenderKey(
                email: email,
                convId: convId,
                senderId: senderId,
                senderDeviceId: senderDeviceId,
                state: senderKey,
                localKey: localKey
            )
        } catch {
            #if DEBUG
            print("Failed to import sender key: \(error)")
            #endif
        }
    }

    // MARK: - Group Decrypt

    /// Decrypt a group message using the sender's distributed Sender Key.
    /// Matches Python: _decrypt_group() in chat_core.py
    func decryptGroupMessage(
        msgDict: [String: Any],
        senderId: String,
        senderDeviceId: String,
        convId: String
    ) -> Data? {
        guard let chainIdB64 = msgDict["sender_chain_id"] as? String,
              let chainN = msgDict["sender_chain_n"] as? Int,
              let chainIdData = try? ProtocolHandler.decodeBinary(chainIdB64) else {
            #if DEBUG
            print("DEBUG decryptGroupMessage: missing sender_chain_id or sender_chain_n")
            #endif
            return nil
        }
        let chainIdHex = chainIdData.hexString

        let ctB64 = msgDict["encrypted_content"] as? String ?? msgDict["ciphertext"] as? String
        guard let ctB64 = ctB64,
              let nonceB64 = msgDict["nonce"] as? String,
              let ciphertext = try? ProtocolHandler.decodeBinary(ctB64),
              let nonce = try? ProtocolHandler.decodeBinary(nonceB64) else {
            #if DEBUG
            print("DEBUG decryptGroupMessage: missing encrypted_content or nonce")
            #endif
            return nil
        }

        // Look up received sender key — try with sender_device_id first
        var sk: SenderKeyState?
        let keyWithDevice = "\(convId):\(senderId):\(senderDeviceId)"
        sk = recvSenderKeys[keyWithDevice]
        if sk == nil {
            sk = KeyStorage.loadRecvSenderKey(
                email: email, convId: convId, senderId: senderId,
                senderDeviceId: senderDeviceId, localKey: localKey
            )
            if let loaded = sk {
                recvSenderKeys[keyWithDevice] = loaded
            }
        }

        // Fallback: try with self device ID (legacy/default)
        if sk == nil && senderDeviceId != Constants.selfDeviceId {
            let keyLegacy = "\(convId):\(senderId):\(Constants.selfDeviceId)"
            sk = recvSenderKeys[keyLegacy]
            if sk == nil {
                sk = KeyStorage.loadRecvSenderKey(
                    email: email, convId: convId, senderId: senderId,
                    senderDeviceId: Constants.selfDeviceId, localKey: localKey
                )
                if let loaded = sk {
                    recvSenderKeys[keyLegacy] = loaded
                }
            }
        }

        guard let senderKey = sk else {
            #if DEBUG
            print("DEBUG decryptGroupMessage: no sender key for \(senderId) in conv \(convId)")
            #endif
            return nil
        }

        do {
            let rawPlaintext = try senderKey.decrypt(chainIdHex: chainIdHex, n: chainN, ciphertext: ciphertext, nonce: nonce)
            let plaintext = MessagePadding.unpad(rawPlaintext)
            // Save updated state (chain has advanced)
            try? KeyStorage.saveRecvSenderKey(
                email: email, convId: convId, senderId: senderId,
                senderDeviceId: senderDeviceId, state: senderKey, localKey: localKey
            )
            #if DEBUG
            print("DEBUG decryptGroupMessage: success, \(plaintext.count) bytes")
            #endif
            return plaintext
        } catch {
            #if DEBUG
            print("DEBUG decryptGroupMessage: decrypt failed: \(error)")
            #endif
            return nil
        }
    }

    /// Decrypt a new_message notification. Matches Python: decrypt_notification().
    /// Supports multi-device format (device_entries array) and legacy flat format.
    func decryptNotification(_ data: [String: Any]) -> Message? {
        guard let senderId = data.string(for: "sender_id"),
              let conversationId = data.string(for: "conversation_id"),
              let messageId = data.string(for: "message_id") else {
            #if DEBUG
            print("DEBUG decryptNotification: missing sender_id/conversation_id/message_id")
            #endif
            return nil
        }

        let senderDeviceId = data.string(for: "sender_device_id") ?? Constants.selfDeviceId
        let myUserId = userId ?? ""
        #if DEBUG
        print("DEBUG decryptNotification: msgId=\(messageId) senderId=\(senderId) senderDeviceId=\(senderDeviceId) convId=\(conversationId)")
        #endif

        // --- Step 1: Extract per-device encrypted content ---
        // Matches Python decrypt_notification lines 1862-1897
        var encryptedContent: String = ""
        var nonce: String = ""
        var ratchetHeader: [String: Any] = [:]
        var x3dhHeader: [String: Any]?

        if let deviceEntries = data["device_entries"] as? [[String: Any]] {
            // Multi-device format: pick entry matching our device_id or SELF_DEVICE_ID
            let entryDeviceIds = deviceEntries.compactMap { $0["device_id"] as? String }
            #if DEBUG
            print("DEBUG decryptNotification: myDeviceId=\(deviceId ?? "nil") device_entries=\(entryDeviceIds)")
            #endif

            var chosen: [String: Any]?
            var selfEntry: [String: Any]?
            for entry in deviceEntries {
                let eid = entry["device_id"] as? String ?? ""
                if eid == deviceId {
                    chosen = entry
                    break
                }
                if eid == Constants.selfDeviceId {
                    selfEntry = entry
                }
            }

            // If sender is us, prefer self-encrypted entry (matches Python lines 1878-1882)
            if senderId == myUserId {
                chosen = selfEntry ?? chosen
            } else if chosen == nil {
                chosen = selfEntry
            }

            guard let chosen = chosen else {
                #if DEBUG
                print("DEBUG decryptNotification: NO matching device_entry for device \(deviceId ?? "nil")")
                #endif
                return nil
            }

            encryptedContent = chosen["encrypted_content"] as? String ?? ""
            nonce = chosen["nonce"] as? String ?? ""
            ratchetHeader = (chosen["ratchet_header"] as? [String: Any]) ?? (data["ratchet_header"] as? [String: Any]) ?? [:]
            x3dhHeader = (chosen["x3dh_header"] as? [String: Any]) ?? (data["x3dh_header"] as? [String: Any])
        } else {
            // Legacy flat format (no device_entries)
            encryptedContent = data["encrypted_content"] as? String ?? data["ciphertext"] as? String ?? ""
            nonce = data["nonce"] as? String ?? ""
            ratchetHeader = data["ratchet_header"] as? [String: Any] ?? [:]
            x3dhHeader = data["x3dh_header"] as? [String: Any]
        }

        guard !encryptedContent.isEmpty, !nonce.isEmpty else {
            #if DEBUG
            print("DEBUG decryptNotification: empty encrypted_content or nonce")
            #endif
            return nil
        }

        // --- Step 2: Build msg_data and decrypt ---
        // Matches Python decrypt_notification lines 1899-1910
        let msgData: [String: Any] = [
            "sender_id": senderId,
            "sender_device_id": senderDeviceId,
            "conversation_id": conversationId,
            "ratchet_header": ratchetHeader,
            "encrypted_content": encryptedContent,
            "nonce": nonce,
            "x3dh_header": x3dhHeader as Any,
            "sender_chain_id": data["sender_chain_id"] as Any,
            "sender_chain_n": data["sender_chain_n"] as Any,
        ]

        // Dispatch: matches Python _decrypt_message() lines 1533-1545
        // Check self-encrypted FIRST (before group check) — after re-encryption,
        // group messages have {"self": true} ratchet_header but still sender_chain_id.
        var plaintext: Data?
        let isSelfEncrypted = (ratchetHeader["self"] as? Bool) == true
            || (ratchetHeader["self"] as? Int) == 1

        if isSelfEncrypted {
            // Self-encrypted copy → static self-key (same path as _decrypt_dm self check)
            #if DEBUG
            print("DEBUG decryptNotification: self-encrypted message")
            #endif
            if let cacheKey = cacheKey {
                if let ct = try? ProtocolHandler.decodeBinary(encryptedContent),
                   let n = try? ProtocolHandler.decodeBinary(nonce),
                   ct.count >= 16 {
                    let ciphertext = ct.prefix(ct.count - 16)
                    let tag = ct.suffix(16)
                    plaintext = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: n, ciphertext: Data(ciphertext), tag: Data(tag))
                    #if DEBUG
                    print("DEBUG decryptNotification: self-decrypt result=\(plaintext != nil ? "OK" : "FAILED")")
                    #endif
                }
            }
        } else if let chainId = msgData["sender_chain_id"] as? String, !chainId.isEmpty, senderId != myUserId {
            // Group message from someone else → sender key decryption
            #if DEBUG
            print("DEBUG decryptNotification: group message")
            #endif
            plaintext = decryptGroupMessage(
                msgDict: msgData, senderId: senderId,
                senderDeviceId: senderDeviceId, convId: conversationId
            )
        } else {
            // DM → ratchet decryption
            #if DEBUG
            print("DEBUG decryptNotification: DM message, calling decryptDMRecipientData")
            #endif
            plaintext = decryptDMRecipientData(
                senderData: msgData,
                senderId: senderId,
                senderDeviceId: senderDeviceId
            )
        }

        guard let plaintext = plaintext else {
            #if DEBUG
            print("DEBUG decryptNotification: decrypt returned nil for msgId=\(messageId)")
            #endif
            return nil
        }

        // --- Step 3: Cache and parse ---
        MessageCache.cacheDecryptedMessage(
            email: email, convId: conversationId, messageId: messageId,
            plaintext: plaintext, cacheKey: cacheKey
        )

        // Queue for self-encryption if from another user (so other devices can read it)
        if senderId != myUserId && !messageId.isEmpty {
            pendingSelfEncrypt.append((messageId: messageId, plaintext: plaintext))
        }

        var messageText: String? = String(data: plaintext, encoding: .utf8)
        var replyTo: String?
        var file: FileInfo?
        var image: ImageInfo?
        var forwardedFrom: ForwardedFrom?
        var senderName = data.string(for: "sender_username") ?? userCache[senderId]?.username ?? "Unknown"

        if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
            messageText = jsonObj["text"] as? String
            replyTo = jsonObj["reply_to"] as? String
            if senderName == "Unknown", let s = jsonObj["sender"] as? String {
                senderName = s
            }
            if let fileDict = jsonObj["file"] as? [String: Any] {
                file = FileInfo(
                    fileId: fileDict["file_id"] as? String ?? "",
                    aesKey: fileDict["aes_key"] as? String ?? "",
                    iv: fileDict["iv"] as? String ?? "",
                    filename: fileDict["filename"] as? String ?? "",
                    size: fileDict["size"] as? Int ?? 0,
                    mimeType: fileDict["mime_type"] as? String ?? ""
                )
            }
            image = parseImageInfo(from: jsonObj)
            if let fwd = jsonObj["forwarded_from"] as? [String: Any],
               let fwdSender = fwd["sender"] as? String {
                forwardedFrom = ForwardedFrom(sender: fwdSender,
                    conversationId: fwd["conversation_id"] as? String ?? "",
                    messageId: fwd["message_id"] as? String ?? "")
            }

            // Check for sender key distribution (control message)
            if jsonObj["_sender_key"] != nil {
                #if DEBUG
                print("DEBUG decryptNotification: control message (sender key distribution)")
                #endif
                return nil
            }
        }

        if messageText == nil && file == nil && image == nil {
            #if DEBUG
            print("DEBUG decryptNotification: control message (no text/file/image)")
            #endif
            return nil
        }

        let createdAt = data.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date()

        return Message(
            id: messageId,
            conversationId: conversationId,
            senderId: senderId,
            senderUsername: senderName,
            createdAt: createdAt,
            text: messageText,
            replyTo: replyTo,
            imageFileId: data.string(for: "image_file_id"),
            file: file,
            image: image,
            isDeleted: false,
            readBy: [],
            reactions: [],
            forwardedFrom: forwardedFrom,
            pinnedAt: nil,
            pinnedBy: nil
        )
    }

    // MARK: - Self-Encrypt Flush

    /// Encrypt received messages with self-encryption key and upload to server.
    /// Allows other devices of the same user to read received messages.
    /// Matches Python: _flush_self_encrypt()
    func flushSelfEncrypt() async {
        guard !pendingSelfEncrypt.isEmpty, let edPriv = identityPrivate else { return }

        let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData)
        var updates: [[String: Any]] = []

        for item in pendingSelfEncrypt {
            guard let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(item.plaintext, key: selfKey) else {
                continue
            }
            let selfCiphertext = ct + tag
            updates.append([
                "message_id": item.messageId,
                "encrypted_content": ProtocolHandler.encodeBinary(selfCiphertext),
                "nonce": ProtocolHandler.encodeBinary(nonce),
            ])
        }
        pendingSelfEncrypt.removeAll()

        // Send in batches of 500 (matches Python batch size)
        let batchSize = 500
        for batchStart in stride(from: 0, to: updates.count, by: batchSize) {
            let batchEnd = min(batchStart + batchSize, updates.count)
            let batch = Array(updates[batchStart..<batchEnd])
            _ = await sendAndReceive(type: "reencrypt_messages", params: ["updates": batch])
        }
    }

    // MARK: - Conversations

    func listConversations() async -> [Conversation] {
        let resp = await sendAndReceive(type: "list_conversations")
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let convList = data["conversations"] as? [[String: Any]] else {
            return []
        }

        return convList.compactMap { dict -> Conversation? in
            guard let id = dict.string(for: "conversation_id") else { return nil }

            let membersRaw = dict["members"] as? [[String: Any]] ?? []
            #if DEBUG
            print("DEBUG listConversations: conv \(id) raw members count=\(membersRaw.count), raw=\(membersRaw)")
            #endif
            let members = membersRaw.compactMap { m -> ConversationMember? in
                guard let uid = m.string(for: "user_id"),
                      let uname = m.string(for: "username"),
                      let uemail = m.string(for: "email") else {
                    #if DEBUG
                    print("DEBUG listConversations: FAILED to parse member: \(m)")
                    #endif
                    return nil
                }
                return ConversationMember(userId: uid, username: uname, email: uemail)
            }
            #if DEBUG
            print("DEBUG listConversations: conv \(id) parsed members count=\(members.count)")
            #endif

            let unreadCount = dict.int(for: "unread_count") ?? 0

            return Conversation(
                id: id,
                name: dict.string(for: "name"),
                members: members,
                createdBy: dict.string(for: "created_by"),
                avatarFile: dict.string(for: "avatar_file"),
                unreadCount: unreadCount,
                isFavorite: false,
                lastMessageTime: nil
            )
        }
    }

    func createConversation(emails: [String], name: String? = nil) async -> (convId: String?, message: String) {
        var params: [String: Any] = ["members": emails]
        if let name = name {
            params["name"] = name
        }

        let resp = await sendAndReceive(type: "create_conversation", params: params)
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let convId = data.string(for: "conversation_id") else {
            let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed to create conversation"
            return (nil, msg)
        }

        return (convId, "Conversation created")
    }

    func findConversation(email: String) async -> String? {
        let resp = await sendAndReceive(type: "find_conversation", params: ["email": email])
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data") else { return nil }
        return data.string(for: "conversation_id")
    }

    // MARK: - Payload Parsing Helpers

    private func parseImageInfo(from jsonObj: [String: Any]) -> ImageInfo? {
        guard let imgDict = jsonObj["image"] as? [String: Any],
              let fileId = imgDict["file_id"] as? String, !fileId.isEmpty else { return nil }
        return ImageInfo(
            fileId: fileId,
            aesKey: imgDict["aes_key"] as? String ?? "",
            iv: imgDict["iv"] as? String ?? "",
            thumbnail: imgDict["thumbnail"] as? String,
            filename: imgDict["filename"] as? String ?? "image.jpg",
            size: imgDict["size"] as? Int ?? 0
        )
    }

    // MARK: - Messages

    func getMessages(convId: String, limit: Int = 50, offset: Int = 0, afterTs: String? = nil) async -> [Message] {
        var params: [String: Any] = [
            "conversation_id": convId,
            "limit": limit,
            "offset": offset,
        ]
        if let afterTs = afterTs {
            params["after_ts"] = afterTs
        }
        let resp = await sendAndReceive(type: "get_messages", params: params)
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let messagesRaw = data["messages"] as? [[String: Any]] else {
            return []
        }

        // Server returns DESC (newest first), reverse to ASC (oldest first) — matches Python client
        let sortedMessages = Array(messagesRaw.reversed())

        // Deduplicate: server can return both device-specific and SELF_DEVICE_ID rows
        // for the same message. Prefer self-encrypted rows (new devices after pairing
        // can't decrypt old device-specific ratchet data, but CAN decrypt self-encrypted).
        var dedupedMessages: [[String: Any]] = []
        var msgIndex: [String: Int] = [:]
        for msgDict in sortedMessages {
            guard let msgId = msgDict.string(for: "message_id") else { continue }
            let rh = msgDict["ratchet_header"] as? [String: Any]
            let isSelf = (rh?["self"] as? Bool) == true || (rh?["self"] as? Int) == 1
            if let idx = msgIndex[msgId] {
                if isSelf { dedupedMessages[idx] = msgDict }
            } else {
                msgIndex[msgId] = dedupedMessages.count
                dedupedMessages.append(msgDict)
            }
        }

        var messages: [Message] = []
        #if DEBUG
        print("DEBUG getMessages: processing \(dedupedMessages.count) messages (from \(sortedMessages.count) rows)")
        #endif
        for msgDict in dedupedMessages {
            guard let msgId = msgDict.string(for: "message_id"),
                  let senderId = msgDict.string(for: "sender_id") else {
                #if DEBUG
                print("DEBUG getMessages: skipping message without message_id or sender_id")
                #endif
                continue
            }

            let senderDeviceId = msgDict.string(for: "sender_device_id") ?? Constants.selfDeviceId
            let isDeleted = msgDict["deleted_at"] != nil && !(msgDict["deleted_at"] is NSNull)
            #if DEBUG
            print("DEBUG getMessages: msgId=\(msgId), senderId=\(senderId), senderDeviceId=\(senderDeviceId), myUserId=\(userId ?? "nil")")
            print("DEBUG getMessages: msgDict keys=\(Array(msgDict.keys))")
            #endif

            // Parse read_by from server response (matches Python: m.get("read_by", []))
            let readBy = Set(msgDict["read_by"] as? [String] ?? [])

            // Parse reactions from server response
            var reactions: [MessageReaction] = []
            if let reactionsArr = msgDict["reactions"] as? [[String: Any]] {
                reactions = reactionsArr.compactMap { r in
                    guard let rUserId = r["user_id"] as? String,
                          let reaction = r["reaction"] as? String else { return nil }
                    let rCreatedAt = (r["created_at"] as? String).flatMap { DateParsing.parse($0) } ?? Date()
                    return MessageReaction(userId: rUserId, reaction: reaction, createdAt: rCreatedAt)
                }
            }

            // Parse pin info from server response
            let pinnedAt = (msgDict["pinned_at"] as? String).flatMap { DateParsing.parse($0) }
            let pinnedBy = msgDict["pinned_by"] as? String

            if isDeleted {
                let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date()
                messages.append(Message(
                    id: msgId, conversationId: convId, senderId: senderId,
                    senderUsername: "",
                    createdAt: createdAt, text: nil, isDeleted: true, readBy: readBy,
                    reactions: [], forwardedFrom: nil, pinnedAt: pinnedAt, pinnedBy: pinnedBy
                ))
                continue
            }

            // Check per-message cache first (ratchet keys are one-time — cannot re-decrypt)
            if let cachedPlaintext = MessageCache.getCachedMessage(
                email: email, convId: convId, messageId: msgId, cacheKey: cacheKey
            ) {
                var messageText: String? = String(data: cachedPlaintext, encoding: .utf8)
                var replyTo: String?
                var file: FileInfo?
                var image: ImageInfo?
                var forwardedFrom: ForwardedFrom?
                var senderName = userCache[senderId]?.username ?? ""
                if let jsonObj = try? JSONSerialization.jsonObject(with: cachedPlaintext) as? [String: Any] {
                    messageText = jsonObj["text"] as? String
                    replyTo = jsonObj["reply_to"] as? String
                    if senderName.isEmpty, let s = jsonObj["sender"] as? String {
                        senderName = s
                    }
                    if let fileDict = jsonObj["file"] as? [String: Any] {
                        file = FileInfo(
                            fileId: fileDict["file_id"] as? String ?? "",
                            aesKey: fileDict["aes_key"] as? String ?? "",
                            iv: fileDict["iv"] as? String ?? "",
                            filename: fileDict["filename"] as? String ?? "",
                            size: fileDict["size"] as? Int ?? 0,
                            mimeType: fileDict["mime_type"] as? String ?? ""
                        )
                    }
                    image = parseImageInfo(from: jsonObj)
                    if let fwd = jsonObj["forwarded_from"] as? [String: Any],
                       let fwdSender = fwd["sender"] as? String {
                        forwardedFrom = ForwardedFrom(sender: fwdSender,
                            conversationId: fwd["conversation_id"] as? String ?? "",
                            messageId: fwd["message_id"] as? String ?? "")
                    }
                }
                if messageText == nil && file == nil && image == nil { continue }  // Control message
                let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date()
                messages.append(Message(
                    id: msgId, conversationId: convId, senderId: senderId,
                    senderUsername: senderName,
                    createdAt: createdAt, text: messageText, replyTo: replyTo,
                    imageFileId: msgDict.string(for: "image_file_id"), file: file,
                    image: image,
                    isDeleted: false, readBy: readBy,
                    reactions: reactions, forwardedFrom: forwardedFrom,
                    pinnedAt: pinnedAt, pinnedBy: pinnedBy
                ))
                #if DEBUG
                print("DEBUG getMessages: loaded from cache msgId=\(msgId)")
                #endif
                continue
            }

            // Try to decrypt (only for messages not in cache)
            // Dispatch: self-encrypted (ratchet_header.self) → static key
            //           group messages (sender_chain_id present, from others) → sender key decrypt
            //           DM or self-copy → ratchet / self-key decrypt
            var plaintext: Data?
            let ratchetHeader = msgDict["ratchet_header"] as? [String: Any]
            let isSelfEncrypted = (ratchetHeader?["self"] as? Bool) == true
                || (ratchetHeader?["self"] as? Int) == 1

            #if DEBUG
            print("DEBUG getMessages: decrypt msgId=\(msgId) senderId=\(senderId) isSelfEncrypted=\(isSelfEncrypted) ratchetHeader=\(ratchetHeader as Any) hasCacheKey=\(cacheKey != nil)")
            #endif

            if isSelfEncrypted, let cacheKey = cacheKey {
                // Re-encrypted message (from reencryptHistory) — decrypt with self-key
                if let ctB64 = (msgDict["encrypted_content"] as? String ?? msgDict["ciphertext"] as? String),
                   let nonceB64 = msgDict["nonce"] as? String,
                   let ct = try? ProtocolHandler.decodeBinary(ctB64),
                   let nonce = try? ProtocolHandler.decodeBinary(nonceB64),
                   ct.count >= 16 {
                    let ciphertext = ct.prefix(ct.count - 16)
                    let tag = ct.suffix(16)
                    plaintext = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: nonce, ciphertext: Data(ciphertext), tag: Data(tag))
                    #if DEBUG
                    print("DEBUG getMessages: self-decrypt msgId=\(msgId) ctLen=\(ct.count) nonceLen=\(nonce.count) result=\(plaintext != nil ? "OK(\(plaintext!.count)B)" : "FAILED")")
                    #endif
                } else {
                    #if DEBUG
                    print("DEBUG getMessages: self-decrypt msgId=\(msgId) SKIPPED (missing ct/nonce)")
                    #endif
                }
            } else if let chainId = msgDict["sender_chain_id"] as? String, !chainId.isEmpty, senderId != userId {
                plaintext = decryptGroupMessage(
                    msgDict: msgDict, senderId: senderId,
                    senderDeviceId: senderDeviceId, convId: convId
                )
            } else {
                plaintext = decryptDMRecipientData(
                    senderData: msgDict, senderId: senderId,
                    senderDeviceId: senderDeviceId
                )
            }

            if let plaintext = plaintext {
                // Cache the decrypted plaintext for future use
                MessageCache.cacheDecryptedMessage(
                    email: email, convId: convId, messageId: msgId,
                    plaintext: plaintext, cacheKey: cacheKey
                )

                // Queue for self-encryption if from another user
                if senderId != userId && !msgId.isEmpty {
                    pendingSelfEncrypt.append((messageId: msgId, plaintext: plaintext))
                }

                let text = String(data: plaintext, encoding: .utf8)
                var messageText = text
                var replyTo: String?
                var file: FileInfo?
                var image: ImageInfo?
                var forwardedFrom: ForwardedFrom?

                var senderName = userCache[senderId]?.username ?? ""
                if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
                    messageText = jsonObj["text"] as? String
                    replyTo = jsonObj["reply_to"] as? String
                    if senderName.isEmpty, let s = jsonObj["sender"] as? String {
                        senderName = s
                    }
                    if let fileDict = jsonObj["file"] as? [String: Any] {
                        file = FileInfo(
                            fileId: fileDict["file_id"] as? String ?? "",
                            aesKey: fileDict["aes_key"] as? String ?? "",
                            iv: fileDict["iv"] as? String ?? "",
                            filename: fileDict["filename"] as? String ?? "",
                            size: fileDict["size"] as? Int ?? 0,
                            mimeType: fileDict["mime_type"] as? String ?? ""
                        )
                    }
                    image = parseImageInfo(from: jsonObj)
                    if let fwd = jsonObj["forwarded_from"] as? [String: Any],
                       let fwdSender = fwd["sender"] as? String {
                        forwardedFrom = ForwardedFrom(sender: fwdSender,
                            conversationId: fwd["conversation_id"] as? String ?? "",
                            messageId: fwd["message_id"] as? String ?? "")
                    }
                }

                if messageText == nil && file == nil && image == nil { continue }  // Control message

                let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date()
                messages.append(Message(
                    id: msgId, conversationId: convId, senderId: senderId,
                    senderUsername: senderName,
                    createdAt: createdAt, text: messageText, replyTo: replyTo,
                    imageFileId: msgDict.string(for: "image_file_id"), file: file,
                    image: image,
                    isDeleted: false, readBy: readBy,
                    reactions: reactions, forwardedFrom: forwardedFrom,
                    pinnedAt: pinnedAt, pinnedBy: pinnedBy
                ))
                #if DEBUG
                print("DEBUG getMessages: decrypted and cached msgId=\(msgId)")
                #endif
            } else {
                #if DEBUG
                print("DEBUG getMessages: decryption failed for msgId=\(msgId)")
                #endif
            }
        }

        // Confirm delivery for messages from others (fire-and-forget)
        let deliverIds = messages.filter { $0.senderId != userId && !$0.isDeleted }.map(\.id)
        if !deliverIds.isEmpty {
            Task { await confirmDelivery(convId: convId, messageIds: deliverIds) }
        }

        // Flush any queued self-encryptions in background
        if !pendingSelfEncrypt.isEmpty {
            Task { await flushSelfEncrypt() }
        }

        #if DEBUG
        print("DEBUG getMessages: returning \(messages.count) messages")
        #endif
        return messages
    }

    func markRead(convId: String, messageIds: [String]) async {
        _ = await sendAndReceive(type: "mark_read", params: [
            "conversation_id": convId,
            "message_ids": messageIds,
        ])
    }

    func markConversationRead(convId: String) async {
        _ = await sendAndReceive(type: "mark_conversation_read", params: [
            "conversation_id": convId,
        ])
    }

    func deleteMessage(messageId: String, convId: String) async -> Bool {
        let resp = await sendAndReceive(type: "delete_message", params: [
            "message_id": messageId,
            "conversation_id": convId,
        ])
        return resp.string(for: "status") == "ok"
    }

    func getDeletedSince(convId: String, sinceTs: String) async -> [String] {
        let resp = await sendAndReceive(type: "get_deleted_since", params: [
            "conversation_id": convId,
            "since_ts": sinceTs,
        ])
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let ids = data["deleted_ids"] as? [String] else { return [] }
        return ids
    }

    func reactMessage(messageId: String, conversationId: String,
                      reaction: String, action: String) async -> Bool {
        let resp = await sendAndReceive(type: "react_message", params: [
            "message_id": messageId,
            "conversation_id": conversationId,
            "reaction": reaction,
            "action": action,
        ])
        return resp.string(for: "status") == "ok"
    }

    func pinMessage(messageId: String, conversationId: String,
                    action: String) async -> Bool {
        let resp = await sendAndReceive(type: "pin_message", params: [
            "message_id": messageId,
            "conversation_id": conversationId,
            "action": action,
        ])
        return resp.string(for: "status") == "ok"
    }

    func getPinnedMessages(conversationId: String) async -> [[String: Any]] {
        let resp = await sendAndReceive(type: "get_pinned_messages", params: [
            "conversation_id": conversationId,
        ])
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let messages = data["messages"] as? [[String: Any]] else { return [] }
        return messages
    }

    // MARK: - Group Operations

    func addMember(convId: String, email: String) async -> (success: Bool, message: String) {
        let resp = await sendAndReceive(type: "add_member", params: [
            "conversation_id": convId,
            "email": email,
        ])
        let msg = resp.dict(for: "data")?.string(for: "message") ?? ""
        return (resp.string(for: "status") == "ok", msg)
    }

    func removeMember(convId: String, userId: String) async -> (success: Bool, message: String) {
        let resp = await sendAndReceive(type: "remove_member", params: [
            "conversation_id": convId,
            "user_id": userId,
        ])
        let msg = resp.dict(for: "data")?.string(for: "message") ?? ""
        return (resp.string(for: "status") == "ok", msg)
    }

    func leaveGroup(convId: String) async -> (success: Bool, message: String) {
        let resp = await sendAndReceive(type: "leave_group", params: [
            "conversation_id": convId,
        ])
        if resp.string(for: "status") == "ok" {
            // Clean up local sender keys
            senderKeyStates.removeValue(forKey: convId)
            KeyStorage.deleteSenderKeyState(email: email, convId: convId)
            KeyStorage.deleteRecvSenderKeys(email: email, convId: convId)
            return (true, "Left group")
        }
        let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed"
        return (false, msg)
    }

    func renameConversation(convId: String, name: String) async -> (success: Bool, message: String) {
        let resp = await sendAndReceive(type: "rename_conversation", params: [
            "conversation_id": convId,
            "name": name,
        ])
        let msg = resp.dict(for: "data")?.string(for: "message") ?? ""
        return (resp.string(for: "status") == "ok", msg)
    }

    func deleteConversation(convId: String) async -> (success: Bool, message: String) {
        let resp = await sendAndReceive(type: "delete_conversation", params: [
            "conversation_id": convId,
        ])
        if resp.string(for: "status") == "ok" {
            senderKeyStates.removeValue(forKey: convId)
            KeyStorage.deleteSenderKeyState(email: email, convId: convId)
            KeyStorage.deleteRecvSenderKeys(email: email, convId: convId)
            return (true, "Deleted")
        }
        let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed"
        return (false, msg)
    }

    // MARK: - Invitations

    func acceptInvitation(convId: String) async -> (success: Bool, message: String) {
        let resp = await sendAndReceive(type: "accept_invitation", params: [
            "conversation_id": convId,
        ])
        let msg = resp.dict(for: "data")?.string(for: "message") ?? ""
        return (resp.string(for: "status") == "ok", msg)
    }

    func declineInvitation(convId: String) async -> (success: Bool, message: String) {
        let resp = await sendAndReceive(type: "decline_invitation", params: [
            "conversation_id": convId,
        ])
        let msg = resp.dict(for: "data")?.string(for: "message") ?? ""
        return (resp.string(for: "status") == "ok", msg)
    }

    func listInvitations() async -> [Invitation] {
        let resp = await sendAndReceive(type: "list_invitations")
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let invList = data["invitations"] as? [[String: Any]] else {
            return []
        }

        return invList.compactMap { dict -> Invitation? in
            guard let convId = dict.string(for: "conversation_id") else { return nil }
            return Invitation(
                id: dict.string(for: "id") ?? convId,
                conversationId: convId,
                conversationName: dict.string(for: "conversation_name") ?? "Group",
                invitedBy: dict.string(for: "invited_by") ?? "",
                invitedByUsername: dict.string(for: "invited_by_username") ?? ""
            )
        }
    }

    // MARK: - Profile

    func getProfile(userId: String? = nil) async -> UserProfile? {
        var params: [String: Any] = [:]
        if let userId = userId {
            params["user_id"] = userId
        }
        let resp = await sendAndReceive(type: "get_profile", params: params)
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data") else { return nil }

        return UserProfile(
            userId: data.string(for: "user_id") ?? userId ?? self.userId ?? "",
            username: data.string(for: "username"),
            email: data.string(for: "email"),
            phone: data.string(for: "phone"),
            phoneVisible: data.bool(for: "phone_visible") ?? false,
            location: data.string(for: "location"),
            locationVisible: data.bool(for: "location_visible") ?? false,
            avatarFile: data.string(for: "avatar_file")
        )
    }

    func updateProfile(phone: String? = nil, phoneVisible: Bool? = nil,
                       location: String? = nil, locationVisible: Bool? = nil) async -> Bool {
        var params: [String: Any] = [:]
        if let phone = phone { params["phone"] = phone }
        if let phoneVisible = phoneVisible { params["phone_visible"] = phoneVisible }
        if let location = location { params["location"] = location }
        if let locationVisible = locationVisible { params["location_visible"] = locationVisible }

        let resp = await sendAndReceive(type: "update_profile", params: params)
        return resp.string(for: "status") == "ok"
    }

    func updateAvatar(imageData: Data) async -> (Bool, String) {
        // Resize avatar to fit within protocol message limit (64KB total)
        guard let uiImage = UIImage(data: imageData) else { return (false, "Invalid image") }

        let maxDim: CGFloat = 256
        let scale = min(1.0, maxDim / max(uiImage.size.width, uiImage.size.height))
        let newSize = CGSize(width: uiImage.size.width * scale, height: uiImage.size.height * scale)
        let renderer = UIGraphicsImageRenderer(size: newSize)
        let processed = renderer.image { _ in
            uiImage.draw(in: CGRect(origin: .zero, size: newSize))
        }

        guard let jpegData = processed.jpegData(compressionQuality: 0.6) else { return (false, "JPEG encode failed") }
        #if DEBUG
        print("DEBUG updateAvatar: jpegData=\(jpegData.count) bytes, base64=\(jpegData.count * 4 / 3) bytes approx")
        #endif

        let resp = await sendAndReceive(type: "update_avatar", params: [
            "data": ProtocolHandler.encodeBinary(jpegData),
        ])
        #if DEBUG
        print("DEBUG updateAvatar: resp=\(resp)")
        #endif
        if resp.string(for: "status") == "ok" {
            return (true, "")
        }
        let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "Upload failed"
        return (false, errMsg)
    }

    func getAvatar(userId: String) async -> Data? {
        let resp = await sendAndReceive(type: "get_avatar", params: ["user_id": userId])
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let avatarB64 = data.string(for: "data"),
              let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else {
            return nil
        }
        return avatarData
    }

    // MARK: - Group Avatar

    func updateGroupAvatar(convId: String, imageData: Data) async -> Bool {
        guard let uiImage = UIImage(data: imageData) else { return false }

        let maxDim: CGFloat = 256
        let scale = min(1.0, maxDim / max(uiImage.size.width, uiImage.size.height))
        let newSize = CGSize(width: uiImage.size.width * scale, height: uiImage.size.height * scale)
        let renderer = UIGraphicsImageRenderer(size: newSize)
        let processed = renderer.image { _ in
            uiImage.draw(in: CGRect(origin: .zero, size: newSize))
        }

        guard let jpegData = processed.jpegData(compressionQuality: 0.6) else { return false }

        let resp = await sendAndReceive(type: "update_group_avatar", params: [
            "conversation_id": convId,
            "data": ProtocolHandler.encodeBinary(jpegData),
        ])
        return resp.string(for: "status") == "ok"
    }

    func getGroupAvatar(convId: String) async -> Data? {
        let resp = await sendAndReceive(type: "get_group_avatar", params: ["conversation_id": convId])
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let avatarB64 = data.string(for: "data"),
              let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else {
            return nil
        }
        return avatarData
    }

    // MARK: - Key Rotation

    func rotateKeys(password: String) async -> (success: Bool, message: String) {
        guard rsaPrivate != nil, userId != nil else {
            return (false, "Not logged in.")
        }

        // Generate new RSA-4096 keypair
        let newPriv: SecKey
        let newPub: SecKey
        do {
            (newPriv, newPub) = try RSACrypto.generateKeypair()
        } catch {
            return (false, "Key generation failed: \(error.localizedDescription)")
        }

        // Save locally (password-encrypted)
        var pwdBytes = Array(password.utf8)
        defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } }
        do {
            try KeyStorage.saveRSAKeys(email: email, privateKey: newPriv, publicKey: newPub, password: Data(pwdBytes))
        } catch {
            return (false, "Failed to save keys: \(error.localizedDescription)")
        }

        // Update in-memory
        self.rsaPrivate = newPriv
        self.rsaPublic = newPub

        // Serialize public key to PEM and send to server
        guard let pubPEM = try? RSACrypto.serializePublicKey(newPub),
              let pubPEMStr = String(data: pubPEM, encoding: .utf8) else {
            return (false, "Failed to serialize public key")
        }

        let resp = await sendAndReceive(type: "rotate_keys", params: ["public_key": pubPEMStr])
        if resp.string(for: "status") == "ok" {
            return (true, "Keys rotated. Other devices will be disconnected.")
        }
        let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "Rotation failed"
        return (false, errMsg)
    }

    // MARK: - Device Pairing

    private var pairingTempPrivateKey: SecKey?
    private var pairingPollToken: String = ""

    /// Start pairing on NEW device: generates temp RSA-2048 key, sends to server, returns 8-digit code
    func pairingStart(email: String) async -> (success: Bool, codeOrMessage: String) {
        // Generate ephemeral RSA-2048 keypair
        let tempPriv: SecKey
        let tempPub: SecKey
        do {
            (tempPriv, tempPub) = try RSACrypto.generateKeypair2048()
        } catch {
            return (false, "Temp key generation failed")
        }
        self.pairingTempPrivateKey = tempPriv

        // Serialize temp public key to PEM
        guard let tempPubPEM = try? RSACrypto.serializePublicKey(tempPub),
              let tempPubStr = String(data: tempPubPEM, encoding: .utf8) else {
            return (false, "Failed to serialize temp key")
        }

        let resp = await sendAndReceive(type: "pairing_start", params: [
            "email": email,
            "temp_public_key": tempPubStr,
        ])

        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data"),
              let code = data.string(for: "code") else {
            let msg = resp.dict(for: "data")?.string(for: "message") ?? "Pairing start failed"
            return (false, msg)
        }

        self.pairingPollToken = data.string(for: "poll_token") ?? ""
        return (true, code)
    }

    /// Wait for pairing on NEW device: polls server every 2s, decrypts keys when ready
    func pairingWait(code: String, email: String, password: String, timeout: TimeInterval = 300) async -> (success: Bool, message: String) {
        guard pairingTempPrivateKey != nil else {
            return (false, "Pairing not started.")
        }

        let deadline = Date().addingTimeInterval(timeout)

        while Date() < deadline {
            let resp = await sendAndReceive(type: "pairing_poll", params: [
                "code": code,
                "poll_token": pairingPollToken,
            ])

            guard resp.string(for: "status") == "ok",
                  let data = resp.dict(for: "data") else {
                let msg = resp.dict(for: "data")?.string(for: "message") ?? "Poll failed"
                return (false, msg)
            }

            guard data.bool(for: "ready") == true else {
                try? await Task.sleep(for: .seconds(2))
                continue
            }

            // Payload received — decrypt keys
            guard let payload = data["payload"] as? [String: Any],
                  let encKeyB64 = payload.string(for: "encrypted_key"),
                  let ivB64 = payload.string(for: "iv"),
                  let ctB64 = payload.string(for: "ciphertext"),
                  let tagB64 = payload.string(for: "tag") else {
                return (false, "Invalid payload format")
            }

            do {
                let encAESKey = try ProtocolHandler.decodeBinary(encKeyB64)
                let iv = try ProtocolHandler.decodeBinary(ivB64)
                let ct = try ProtocolHandler.decodeBinary(ctB64)
                let tag = try ProtocolHandler.decodeBinary(tagB64)

                // RSA-OAEP decrypt the AES key
                let aesKey = try RSACrypto.decrypt(pairingTempPrivateKey!, ciphertext: encAESKey)

                // AES-GCM decrypt the keys JSON
                let keysJSON = try CryptoUtils.aesDecrypt(key: aesKey, nonce: iv, ciphertext: ct, tag: tag)

                guard let keysDict = try JSONSerialization.jsonObject(with: keysJSON) as? [String: String],
                      let rsaPrivPEM = keysDict["rsa_private"],
                      let identityHex = keysDict["identity_private"] else {
                    return (false, "Invalid keys JSON")
                }

                // Import RSA key
                var pwdBytes = Array(password.utf8)
                defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } }
                let pwdData = Data(pwdBytes)

                let rsaPriv = try RSACrypto.loadPrivateKey(Data(rsaPrivPEM.utf8), password: nil)
                let rsaPub = SecKeyCopyPublicKey(rsaPriv)!

                // Import Ed25519 identity key
                guard let identityRaw = Data(hexString: identityHex), identityRaw.count == 32 else {
                    return (false, "Invalid identity key format")
                }
                let edPriv = try Curve25519.Signing.PrivateKey(rawRepresentation: identityRaw)
                let edPub = edPriv.publicKey

                // Save to disk (encrypted with password)
                try KeyStorage.saveRSAKeys(email: email, privateKey: rsaPriv, publicKey: rsaPub, password: pwdData)
                try KeyStorage.saveIdentityKeys(email: email, privateKey: edPriv, publicKey: edPub, password: pwdData)

                // Update in-memory state
                self.email = email
                self.rsaPrivate = rsaPriv
                self.rsaPublic = rsaPub
                self.identityPrivate = edPriv
                self.identityPublic = edPub
                self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData)
                self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: edPriv.rawData)

                // Clear pairing state
                self.pairingTempPrivateKey = nil
                self.pairingPollToken = ""

                return (true, "Pairing complete.")
            } catch {
                return (false, "Failed to import keys: \(error.localizedDescription)")
            }
        }

        return (false, "Pairing timed out.")
    }

    /// Re-encrypt all cached messages with self-encryption key so a paired device can read them.
    /// Called by authorizeDevice() before sending keys.
    func reencryptHistory() async {
        guard let edPriv = identityPrivate else { return }
        let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData)

        // Phase 1: Fetch all messages to populate cache (decrypts and caches them)
        let convs = await listConversations()
        for conv in convs {
            var offset = 0
            while true {
                let msgs = await getMessages(convId: conv.id, limit: 200, offset: offset)
                if msgs.count < 200 { break }
                offset += msgs.count
            }
        }

        // Phase 2: Read per-message cache and re-encrypt with self-key
        var allUpdates: [[String: String]] = []
        for conv in convs {
            let cachedMessages = MessageCache.loadAllCachedMessages(email: email, convId: conv.id, cacheKey: cacheKey)
            for (msgId, plaintext) in cachedMessages {
                // Skip control messages
                if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
                    if jsonObj["_control"] != nil { continue }
                    let text = jsonObj["text"] as? String
                    let image = jsonObj["image"]
                    let file = jsonObj["file"]
                    if text == nil && image == nil && file == nil { continue }
                }

                // Re-encrypt with self-encryption key
                guard let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) else { continue }

                allUpdates.append([
                    "message_id": msgId,
                    "encrypted_content": ProtocolHandler.encodeBinary(ct + tag),
                    "nonce": ProtocolHandler.encodeBinary(nonce),
                ])
            }
        }

        if allUpdates.isEmpty { return }

        // Send in batches of 500
        let batchSize = 500
        for start in stride(from: 0, to: allUpdates.count, by: batchSize) {
            let end = min(start + batchSize, allUpdates.count)
            let batch = Array(allUpdates[start..<end])
            let resp = await sendAndReceive(type: "reencrypt_messages", params: ["updates": batch])
            if resp.string(for: "status") == "ok" {
                #if DEBUG
                print("DEBUG reencryptHistory: re-encrypted \(end)/\(allUpdates.count) messages")
                #endif
            } else {
                #if DEBUG
                print("DEBUG reencryptHistory: batch failed: \(resp.dict(for: "data")?.string(for: "message") ?? "unknown")")
                #endif
            }
        }
        #if DEBUG
        print("DEBUG reencryptHistory: complete, \(allUpdates.count) messages re-encrypted")
        #endif
    }

    /// Authorize a new device from the EXISTING device: encrypts and sends keys
    func authorizeDevice(code: String) async -> (success: Bool, message: String) {
        guard let rsaPriv = rsaPrivate, let edPriv = identityPrivate else {
            return (false, "Not logged in.")
        }

        // Claim the pairing code — get temp public key
        let claimResp = await sendAndReceive(type: "pairing_claim", params: ["code": code])
        guard claimResp.string(for: "status") == "ok",
              let claimData = claimResp.dict(for: "data"),
              let tempPubPEM = claimData.string(for: "temp_public_key") else {
            let msg = claimResp.dict(for: "data")?.string(for: "message") ?? "Claim failed"
            return (false, msg)
        }

        // Load temp public key
        let tempPub: SecKey
        do {
            tempPub = try RSACrypto.loadPublicKey(Data(tempPubPEM.utf8))
        } catch {
            return (false, "Invalid temp public key: \(error.localizedDescription)")
        }

        // Re-encrypt message history so new device can read old messages
        await reencryptHistory()

        // Build keys payload (RSA private unencrypted PEM + Ed25519 raw hex)
        let rsaPrivPEM: String
        do {
            let pemData = try RSACrypto.serializePrivateKey(rsaPriv, password: nil)
            rsaPrivPEM = String(data: pemData, encoding: .utf8) ?? ""
        } catch {
            return (false, "Failed to serialize RSA key")
        }

        let identityHex = edPriv.rawRepresentation.map { String(format: "%02x", $0) }.joined()

        let keysDict: [String: String] = [
            "rsa_private": rsaPrivPEM,
            "identity_private": identityHex,
        ]

        guard let keysJSON = try? JSONSerialization.data(withJSONObject: keysDict) else {
            return (false, "JSON serialization failed")
        }

        // Encrypt: AES-GCM for payload, RSA-OAEP for AES key
        do {
            let (aesKey, nonce, ct, tag) = try CryptoUtils.aesEncrypt(keysJSON)
            let encAESKey = try RSACrypto.encrypt(tempPub, plaintext: aesKey)

            let payload: [String: String] = [
                "encrypted_key": ProtocolHandler.encodeBinary(encAESKey),
                "iv": ProtocolHandler.encodeBinary(nonce),
                "ciphertext": ProtocolHandler.encodeBinary(ct),
                "tag": ProtocolHandler.encodeBinary(tag),
            ]

            let resp = await sendAndReceive(type: "pairing_send", params: [
                "code": code,
                "payload": payload,
            ])

            if resp.string(for: "status") == "ok" {
                return (true, "Device authorized.")
            }
            let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed"
            return (false, msg)
        } catch {
            return (false, "Encryption failed: \(error.localizedDescription)")
        }
    }

    // MARK: - File Sharing

    func sendFile(convId: String, fileData: Data, filename: String, mimeType: String,
                  members: [ConversationMember], replyTo: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) {
        // Encrypt file with AES-GCM
        guard let (aesKey, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(fileData) else {
            return (false, "File encryption failed", nil)
        }

        let encryptedData = ct + tag
        let fileType = mimeType.hasPrefix("image/") ? "image" : "file"

        // Start upload
        let fileId = UUID().uuidString.lowercased()
        let startResp = await sendAndReceive(type: "upload_image_start", params: [
            "conversation_id": convId,
            "file_id": fileId,
            "file_size": encryptedData.count,
            "file_type": fileType,
        ])
        guard startResp.string(for: "status") == "ok" else {
            let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Upload start failed"
            return (false, msg, nil)
        }

        // Upload chunks
        var offset = 0
        while offset < encryptedData.count {
            let end = min(offset + Constants.imageChunkSize, encryptedData.count)
            let chunk = encryptedData[offset..<end]
            let chunkNum = offset / Constants.imageChunkSize + 1
            let chunkResp = await sendAndReceive(type: "upload_image_chunk", timeout: 60, params: [
                "file_id": fileId,
                "data": ProtocolHandler.encodeBinary(Data(chunk)),
                "offset": offset,
            ])
            guard chunkResp.string(for: "status") == "ok" else {
                return (false, "Chunk upload failed (chunk \(chunkNum))", nil)
            }
            offset = end
        }

        // End upload
        let endResp = await sendAndReceive(type: "upload_image_end", timeout: 60, params: [
            "file_id": fileId,
        ])
        guard endResp.string(for: "status") == "ok" else {
            return (false, "Upload completion failed", nil)
        }

        // Send message with file info
        // Keys must be base64 encoded to match Python server
        let fileInfo: [String: Any] = [
            "file_id": fileId,
            "aes_key": ProtocolHandler.encodeBinary(aesKey),
            "iv": ProtocolHandler.encodeBinary(nonce),
            "filename": filename,
            "size": fileData.count,
            "mime_type": mimeType,
        ]

        let extra: [String: Any] = ["file": fileInfo]
        let (success, msg, sentMessage) = await sendMessage(
            convId: convId, text: "", members: members, replyTo: replyTo,
            extraPayload: extra, imageFileId: fileId
        )
        return (success, msg, sentMessage)
    }

    func sendImage(convId: String, imageData: Data, members: [ConversationMember],
                   replyTo: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) {

        // Step 1: Process image on MainActor (UIKit requires main thread)
        let processed = await MainActor.run { () -> (imageBytes: Data, thumbnailB64: String, originalSize: Int)? in
            guard let uiImage = UIImage(data: imageData) else { return nil }

            var finalBytes = imageData
            var img = uiImage
            // AES-GCM overhead: 12 nonce + 16 tag = 28 bytes
            let maxPlaintextSize = Constants.maxImageBytes - 28

            // If too large, progressively compress
            if finalBytes.count > maxPlaintextSize {
                let qualities: [CGFloat] = [0.92, 0.85, 0.75, 0.60]
                var fits = false
                for quality in qualities {
                    if let compressed = img.jpegData(compressionQuality: quality) {
                        finalBytes = compressed
                        if compressed.count <= maxPlaintextSize { fits = true; break }
                    }
                }

                if !fits {
                    // Downscale dimensions
                    for maxDim: CGFloat in [3840, 2560, 1920, 1280] {
                        if max(img.size.width, img.size.height) > maxDim {
                            let scale = maxDim / max(img.size.width, img.size.height)
                            let newSize = CGSize(width: img.size.width * scale, height: img.size.height * scale)
                            let renderer = UIGraphicsImageRenderer(size: newSize)
                            img = renderer.image { _ in img.draw(in: CGRect(origin: .zero, size: newSize)) }
                        }
                        if let compressed = img.jpegData(compressionQuality: 0.75) {
                            finalBytes = compressed
                            if compressed.count <= maxPlaintextSize { break }
                        }
                    }
                }
            }

            // Generate thumbnail (200x200, quality 60)
            let thumbScale = min(200.0 / max(img.size.width, img.size.height), 1.0)
            let thumbDim = CGSize(width: img.size.width * thumbScale, height: img.size.height * thumbScale)
            let thumbRenderer = UIGraphicsImageRenderer(size: thumbDim)
            let thumbImg = thumbRenderer.image { _ in img.draw(in: CGRect(origin: .zero, size: thumbDim)) }
            let thumbB64 = thumbImg.jpegData(compressionQuality: 0.6)?.base64EncodedString() ?? ""

            return (finalBytes, thumbB64, imageData.count)
        }

        guard let processed = processed else {
            return (false, "Invalid image data", nil)
        }

        // Step 2: Encrypt on background (actor isolates this automatically)
        guard let (aesKey, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(processed.imageBytes) else {
            return (false, "Image encryption failed", nil)
        }
        let encryptedData = ct + tag

        // Step 3: Chunked upload
        let fileId = UUID().uuidString.lowercased()
        let startResp = await sendAndReceive(type: "upload_image_start", params: [
            "conversation_id": convId,
            "file_id": fileId,
            "file_size": encryptedData.count,
            "file_type": "image",
        ])
        guard startResp.string(for: "status") == "ok" else {
            let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Upload start failed"
            return (false, msg, nil)
        }

        var offset = 0
        while offset < encryptedData.count {
            let end = min(offset + Constants.imageChunkSize, encryptedData.count)
            let chunk = encryptedData[offset..<end]
            let chunkResp = await sendAndReceive(type: "upload_image_chunk", timeout: 60, params: [
                "file_id": fileId,
                "data": ProtocolHandler.encodeBinary(Data(chunk)),
                "offset": offset,
            ])
            guard chunkResp.string(for: "status") == "ok" else {
                return (false, "Chunk upload failed", nil)
            }
            offset = end
        }

        let endResp = await sendAndReceive(type: "upload_image_end", timeout: 60, params: ["file_id": fileId])
        guard endResp.string(for: "status") == "ok" else {
            return (false, "Upload completion failed", nil)
        }

        // Step 4: Send message with image info
        let imageInfo: [String: Any] = [
            "file_id": fileId,
            "aes_key": ProtocolHandler.encodeBinary(aesKey),
            "iv": ProtocolHandler.encodeBinary(nonce),
            "thumbnail": processed.thumbnailB64,
            "filename": "image.jpg",
            "size": processed.originalSize,
        ]

        let extra: [String: Any] = ["image": imageInfo]
        let (success, msg, sentMessage) = await sendMessage(
            convId: convId, text: "", members: members, replyTo: replyTo,
            extraPayload: extra, imageFileId: fileId
        )
        return (success, msg, sentMessage)
    }

    func downloadFile(fileId: String, aesKey: Data, iv: Data, conversationId: String? = nil) async -> Data? {
        #if DEBUG
        print("DEBUG downloadFile: starting download for fileId=\(fileId) convId=\(conversationId ?? "nil") aesKeyLen=\(aesKey.count) ivLen=\(iv.count)")
        #endif
        var allData = Data()
        var offset = 0

        while true {
            var params: [String: Any] = [
                "file_id": fileId,
                "offset": offset,
            ]
            if let conversationId = conversationId {
                params["conversation_id"] = conversationId
            }
            let resp = await sendAndReceive(type: "download_image", params: params)

            let status = resp.string(for: "status")
            guard status == "ok" else {
                let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "unknown"
                #if DEBUG
                print("DEBUG downloadFile: server error status=\(status ?? "nil") msg=\(errMsg)")
                #endif
                break
            }
            guard let data = resp.dict(for: "data") else {
                #if DEBUG
                print("DEBUG downloadFile: missing 'data' dict in response, keys=\(Array(resp.keys))")
                #endif
                break
            }
            guard let chunkB64 = data.string(for: "data") else {
                #if DEBUG
                print("DEBUG downloadFile: missing 'data' string in data dict, keys=\(Array(data.keys))")
                #endif
                break
            }
            guard let chunk = try? ProtocolHandler.decodeBinary(chunkB64) else {
                #if DEBUG
                print("DEBUG downloadFile: base64 decode failed, chunkB64 length=\(chunkB64.count)")
                #endif
                break
            }

            if chunk.isEmpty {
                #if DEBUG
                print("DEBUG downloadFile: empty chunk received, done")
                #endif
                break
            }
            allData.append(chunk)
            offset += chunk.count
            #if DEBUG
            print("DEBUG downloadFile: received chunk \(chunk.count) bytes, total=\(allData.count)")
            #endif

            if data.bool(for: "done") == true {
                #if DEBUG
                print("DEBUG downloadFile: server signaled done")
                #endif
                break
            }
        }

        guard !allData.isEmpty else {
            #if DEBUG
            print("DEBUG downloadFile: no data downloaded for fileId=\(fileId)")
            #endif
            return nil
        }

        // Decrypt: allData = ciphertext + tag(16)
        guard allData.count >= 16 else {
            #if DEBUG
            print("DEBUG downloadFile: data too short (\(allData.count) bytes) for fileId=\(fileId)")
            #endif
            return nil
        }
        let ct = allData.prefix(allData.count - 16)
        let tag = allData.suffix(16)
        #if DEBUG
        print("DEBUG downloadFile: decrypting \(ct.count) bytes ciphertext + 16 bytes tag")
        #endif
        do {
            let result = try CryptoUtils.aesDecrypt(key: aesKey, nonce: iv, ciphertext: Data(ct), tag: Data(tag))
            #if DEBUG
            print("DEBUG downloadFile: decryption success, \(result.count) bytes")
            #endif
            return result
        } catch {
            #if DEBUG
            print("DEBUG downloadFile: decryption FAILED: \(error)")
            #endif
            return nil
        }
    }

    // MARK: - Devices

    func listDevices() async -> [[String: Any]] {
        let resp = await sendAndReceive(type: "list_devices")
        guard resp.string(for: "status") == "ok",
              let data = resp.dict(for: "data") else { return [] }
        return data["devices"] as? [[String: Any]] ?? []
    }

    func removeDevice(deviceIdToRemove: String) async -> Bool {
        let resp = await sendAndReceive(type: "remove_device", params: [
            "device_id": deviceIdToRemove,
        ])
        return resp.string(for: "status") == "ok"
    }

    // MARK: - Session Reset

    func resetSession(peerUserId: String, peerDeviceId: String? = nil) async {
        if let peerDeviceId = peerDeviceId {
            let sessionKey = "\(peerUserId):\(peerDeviceId)"
            sessions.removeValue(forKey: sessionKey)
            KeyStorage.deleteSession(email: email, peerUserId: peerUserId, peerDeviceId: peerDeviceId)
        } else {
            // Delete all sessions for this user
            for key in sessions.keys where key.hasPrefix(peerUserId) {
                sessions.removeValue(forKey: key)
            }
            KeyStorage.deleteSession(email: email, peerUserId: peerUserId)
        }

        _ = await sendAndReceive(type: "session_reset", params: [
            "peer_user_id": peerUserId,
            "peer_device_id": peerDeviceId ?? "",
        ])
    }

    func handleSessionResetNotification(fromUserId: String, fromDeviceId: String?) {
        if let deviceId = fromDeviceId {
            let sessionKey = "\(fromUserId):\(deviceId)"
            sessions.removeValue(forKey: sessionKey)
            KeyStorage.deleteSession(email: email, peerUserId: fromUserId, peerDeviceId: deviceId)
        } else {
            for key in sessions.keys where key.hasPrefix(fromUserId) {
                sessions.removeValue(forKey: key)
            }
            KeyStorage.deleteSession(email: email, peerUserId: fromUserId)
        }
    }

    // MARK: - Search

    func searchMessages(convId: String, query: String) -> [[String: Any]] {
        MessageCache.search(email: email, convId: convId, query: query, cacheKey: cacheKey)
    }

    // MARK: - Change Username

    func changeUsername(newUsername: String) async -> (success: Bool, message: String) {
        let trimmed = newUsername.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, trimmed.count <= 100 else {
            return (false, "Username must be 1-100 characters.")
        }
        let resp = await sendAndReceive(type: "change_username", params: ["username": trimmed])
        if resp.string(for: "status") == "ok" {
            if let data = resp.dict(for: "data"), let newName = data.string(for: "username") {
                username = newName
            } else {
                username = trimmed
            }
            return (true, "Username changed.")
        }
        let msg = resp.dict(for: "data")?.string(for: "message") ?? "Unknown error"
        return (false, msg)
    }

    // MARK: - Change Password

    func changePassword(oldPassword: String, newPassword: String) -> (success: Bool, message: String) {
        guard !email.isEmpty else {
            return (false, "Not logged in.")
        }

        let oldPwd = Data(oldPassword.utf8)
        let newPwd = Data(newPassword.utf8)

        // 1. Verify old password by loading keys
        let (rsaPriv, rsaPub, err) = KeyStorage.loadRSAKeys(email: email, password: oldPwd)
        guard let rsaPriv = rsaPriv, let rsaPub = rsaPub else {
            return (false, err ?? "Wrong current password.")
        }

        let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: oldPwd)
        guard let edPriv = edPriv, let edPub = edPub else {
            return (false, "Failed to load identity key.")
        }

        // 2. Re-save with new password
        do {
            try KeyStorage.saveRSAKeys(email: email, privateKey: rsaPriv, publicKey: rsaPub, password: newPwd)
            try KeyStorage.saveIdentityKeys(email: email, privateKey: edPriv, publicKey: edPub, password: newPwd)
            return (true, "Password changed successfully.")
        } catch {
            return (false, "Failed to save keys: \(error.localizedDescription)")
        }
    }

    // MARK: - Confirm Delivery

    func confirmDelivery(convId: String, messageIds: [String]) async {
        guard !messageIds.isEmpty else { return }
        // Fire-and-forget, non-critical
        _ = await sendAndReceive(type: "confirm_delivery", params: [
            "conversation_id": convId,
            "message_ids": messageIds,
        ])
    }

    // MARK: - Forward Message

    func forwardMessage(targetConvId: String, originalMsg: [String: Any],
                        targetMembers: [ConversationMember]) async -> (success: Bool, message: String, sentMessage: Message?) {
        let text = originalMsg["text"] as? String ?? ""

        var payload: [String: Any] = [
            "sender": username,
            "text": text,
            "forwarded_from": [
                "sender": originalMsg["sender"] as? String ?? "",
                "conversation_id": originalMsg["conversation_id"] as? String ?? "",
                "message_id": originalMsg["message_id"] as? String ?? "",
            ] as [String: Any],
            "timestamp": ISO8601DateFormatter().string(from: Date()),
        ]

        // Forward image/file metadata (the encrypted blob is already on the server)
        if let image = originalMsg["image"] {
            payload["image"] = image
            if text.isEmpty { payload["text"] = "" }
        }
        if let file = originalMsg["file"] {
            payload["file"] = file
            if text.isEmpty { payload["text"] = "" }
        }

        var extraPayload: [String: Any] = [:]
        if let fwd = payload["forwarded_from"] {
            extraPayload["forwarded_from"] = fwd
        }
        if let image = payload["image"] {
            extraPayload["image"] = image
        }
        if let file = payload["file"] {
            extraPayload["file"] = file
        }

        return await sendMessage(
            convId: targetConvId, text: text, members: targetMembers,
            extraPayload: extraPayload
        )
    }

    // MARK: - Proof of Work

    static func solvePow(challenge: String, difficulty: Int) -> String {
        let targetBytes = difficulty / 8
        let targetBits = difficulty % 8
        let mask: UInt8 = targetBits > 0 ? UInt8((0xFF << (8 - targetBits)) & 0xFF) : 0

        var nonce: UInt64 = 0
        while true {
            let input = "\(challenge)\(nonce)"
            let inputData = Data(input.utf8)
            let digest = SHA256.hash(data: inputData)
            let digestBytes = Array(digest)

            var ok = true
            for i in 0..<targetBytes {
                if digestBytes[i] != 0 {
                    ok = false
                    break
                }
            }
            if ok && targetBits > 0 {
                if digestBytes[targetBytes] & mask != 0 {
                    ok = false
                }
            }
            if ok {
                return String(nonce)
            }
            nonce += 1
        }
    }

    // MARK: - TOFU / Contact Verification

    /// Load TOFU and verification stores from disk
    private func loadVerificationStores() {
        guard !email.isEmpty else { return }
        knownIdentityKeys = KeyStorage.loadKnownIdentityKeys(email: email, localKey: localKey)
        verifiedContacts = KeyStorage.loadVerifiedContacts(email: email, localKey: localKey)
    }

    /// Check a user's identity key against TOFU registry
    func checkIdentityKey(userId: String, identityKeyBytes: Data) -> String {
        let ikHex = identityKeyBytes.hexString
        let now = ISO8601DateFormatter().string(from: Date())
        let known = knownIdentityKeys[userId]

        if known == nil {
            // TOFU: trust on first use
            knownIdentityKeys[userId] = [
                "identity_key": ikHex,
                "first_seen": now,
                "last_seen": now,
            ]
            try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey)
            return "new"
        }

        if known?["identity_key"] == ikHex {
            knownIdentityKeys[userId]?["last_seen"] = now
            try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey)
            let verified = verifiedContacts[userId]
            if verified != nil && verified?["identity_key"] == ikHex {
                return "verified"
            }
            return "trusted"
        }

        // Key has CHANGED
        let wasVerified = verifiedContacts[userId] != nil
        return wasVerified ? "changed_verified" : "changed"
    }

    /// Mark a contact's identity key as explicitly verified
    func verifyContact(userId: String, identityKey: Data, method: String = "manual") {
        let ikHex = identityKey.hexString
        let now = ISO8601DateFormatter().string(from: Date())
        verifiedContacts[userId] = [
            "identity_key": ikHex,
            "verified_at": now,
            "method": method,
        ]
        if knownIdentityKeys[userId] == nil {
            knownIdentityKeys[userId] = [
                "identity_key": ikHex,
                "first_seen": now,
                "last_seen": now,
            ]
        } else {
            knownIdentityKeys[userId]?["last_seen"] = now
        }
        try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey)
        try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey)
    }

    /// Remove explicit verification for a contact
    func unverifyContact(userId: String) {
        verifiedContacts.removeValue(forKey: userId)
        try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey)
    }

    /// Accept a changed identity key
    func acceptKeyChange(userId: String, newIdentityKey: Data) {
        let ikHex = newIdentityKey.hexString
        let now = ISO8601DateFormatter().string(from: Date())
        knownIdentityKeys[userId] = [
            "identity_key": ikHex,
            "first_seen": now,
            "last_seen": now,
        ]
        verifiedContacts.removeValue(forKey: userId)
        try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey)
        try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey)
    }

    /// Get verification status for a user
    func getVerificationStatus(userId: String) -> String {
        if let verified = verifiedContacts[userId] {
            let known = knownIdentityKeys[userId]
            if known?["identity_key"] == verified["identity_key"] {
                return "verified"
            }
        }
        if knownIdentityKeys[userId] != nil {
            return "trusted"
        }
        return "unverified"
    }

    /// Get formatted safety number for a peer
    func getSafetyNumber(peerUserId: String) -> String? {
        guard let idPub = identityPublic, let myId = userId else { return nil }
        let myIK = idPub.rawData
        guard let peerIK = getPeerIdentityKeySync(userId: peerUserId) else { return nil }
        return ContactVerification.computeSafetyNumber(
            myUserId: myId, myIdentityKey: myIK,
            theirUserId: peerUserId, theirIdentityKey: peerIK
        )
    }

    /// Get formatted fingerprint for own identity key
    func getMyFingerprint() -> String? {
        guard let idPub = identityPublic, let myId = userId else { return nil }
        let fp = ContactVerification.computeFingerprint(userId: myId, identityKey: idPub.rawData)
        return ContactVerification.formatFingerprint(fp)
    }

    /// Get formatted fingerprint for a peer's identity key
    func getPeerFingerprint(peerUserId: String) -> String? {
        guard let peerIK = getPeerIdentityKeySync(userId: peerUserId) else { return nil }
        let fp = ContactVerification.computeFingerprint(userId: peerUserId, identityKey: peerIK)
        return ContactVerification.formatFingerprint(fp)
    }

    /// Get QR code payload bytes for own identity
    func getVerificationQRData() -> Data? {
        guard let idPub = identityPublic, let myId = userId else { return nil }
        return ContactVerification.encodeVerificationQR(userId: myId, identityKey: idPub.rawData)
    }

    /// Verify a scanned QR code against known identity keys
    func verifyQRCode(qrData: Data) -> (success: Bool, userId: String, message: String) {
        let decoded: (userId: String, identityKey: Data)
        do {
            decoded = try ContactVerification.decodeVerificationQR(qrData)
        } catch {
            return (false, "", "Invalid QR code: \(error.localizedDescription)")
        }
        guard let peerIK = getPeerIdentityKeySync(userId: decoded.userId) else {
            return (false, decoded.userId, "Unknown user — not in your contacts.")
        }
        guard peerIK == decoded.identityKey else {
            return (false, decoded.userId, "Identity key MISMATCH — verification failed!")
        }
        verifyContact(userId: decoded.userId, identityKey: decoded.identityKey, method: "qr_code")
        let name = userCache[decoded.userId]?.username ?? String(decoded.userId.prefix(8))
        return (true, decoded.userId, "Verified \(name) via QR code.")
    }

    /// Get a peer's identity key bytes (from cache, TOFU registry, or server)
    func getPeerIdentityKey(userId: String) async -> Data? {
        // 1. Check local caches first
        if let ik = getPeerIdentityKeySync(userId: userId) {
            return ik
        }
        // 2. Fetch from server
        if let user = await getUserInfo(userId: userId) {
            return user.identityKey
        }
        return nil
    }

    /// Sync lookup: user cache → TOFU registry
    private func getPeerIdentityKeySync(userId: String) -> Data? {
        if let ik = userCache[userId]?.identityKey {
            return ik
        }
        if let ikHex = knownIdentityKeys[userId]?["identity_key"],
           let ikData = Data(hexString: ikHex) {
            return ikData
        }
        return nil
    }

    // MARK: - Brute-Force Lockout

    static func checkLockout(email: String) -> TimeInterval {
        guard let dir = try? KeyStorage.getKeyDir(email: email) else { return 0 }
        let path = dir.appendingPathComponent("login_lockout.json")
        guard let data = try? Data(contentsOf: path),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let lockedUntil = json["locked_until"] as? TimeInterval else { return 0 }
        return max(0, lockedUntil - Date().timeIntervalSince1970)
    }

    static func recordFailedAttempt(email: String) {
        guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
        let path = dir.appendingPathComponent("login_lockout.json")
        var failed = 0
        if let data = try? Data(contentsOf: path),
           let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
            failed = json["failed_attempts"] as? Int ?? 0
        }
        failed += 1
        let delay = min(pow(lockoutBaseSeconds, Double(failed)), lockoutMaxSeconds)
        let lockedUntil = Date().timeIntervalSince1970 + delay
        let json: [String: Any] = ["failed_attempts": failed, "locked_until": lockedUntil]
        if let data = try? JSONSerialization.data(withJSONObject: json) {
            try? data.write(to: path, options: .completeFileProtection)
        }
    }

    static func clearLockout(email: String) {
        guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
        let path = dir.appendingPathComponent("login_lockout.json")
        try? FileManager.default.removeItem(at: path)
    }
}
