// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Thibault Ducray
//
// This file is part of MyPwdTool's open-source sync/encryption core — see
// LICENSE-SYNC-CRYPTO.md at the repo root and https://tducray.fr/mypwdtool/open-source/.
// The rest of this application is proprietary and NOT covered by this license.

import Foundation
import CryptoKit
import Combine

// MARK: - SyncError

enum SyncError: LocalizedError {
    case invalidServerURL
    case invalidPairingCode
    case oneShotInviteAlreadyUsed

    var errorDescription: String? {
        switch self {
        case .invalidServerURL:   return String(localized: "Invalid server URL")
        case .invalidPairingCode: return String(localized: "Invalid pairing code")
        case .oneShotInviteAlreadyUsed:
            return String(localized: "You've already used this device's one-time invite — subscribe to MyPwdTool sync on iOS/macOS, or use the device with which you subscribed to add more devices.")
        }
    }
}

// MARK: - SyncManager

@MainActor
final class SyncManager: ObservableObject {

    static let shared = SyncManager()

    @Published private(set) var isConfigured: Bool = false
    @Published private(set) var isBusy: Bool = false
    @Published private(set) var peers: [SyncPeer] = []
    /// Non-nil only while an initial bulk resync (after pairing a new device) is in flight.
    /// Set to nil the moment it completes or fails — a view can show a progress bar only
    /// while this is non-nil, e.g. `ProgressView(value: Double(p.sent), total: Double(p.total))`.
    @Published private(set) var syncProgress: SyncProgress?

    private var pollTask: Task<Void, Never>?
    private var drainTask: Task<Void, Never>?
    private var cancellables = Set<AnyCancellable>()

    // Short-poll intervals (connection is never held open)
    #if os(iOS)
    private let pollInterval: Duration = .seconds(120)   // 2 min while foreground
    #else
    private let pollInterval: Duration = .seconds(60)    // 1 min on macOS
    #endif
    private let fastPollInterval: Duration = .seconds(10)

    /// Deadline-based fast poll: active while Date() < fastPollDeadline.
    /// Set by enableFastPoll(for:) — e.g. 3 minutes after joining.
    private var fastPollDeadline: Date? = nil

    /// Message-count-based fast poll: true when the last batch was full (100 msgs),
    /// meaning there are likely more messages waiting. Reverts on a non-full batch.
    private var fastPollOnFullBatch = false

    private var isInFastPollMode: Bool {
        fastPollOnFullBatch ||
        fastPollDeadline.map { Date() < $0 } ?? false
    }

    // ≈ 16 messages/second — comfortably under the 20 req/s relay limit
    private let sendDelay: Duration = .milliseconds(60)

    // Minimum gap between heartbeat broadcasts — keeps a mostly-quiet, mostly-receiving
    // device from looking permanently stale to its peers without flooding the relay.
    private let heartbeatInterval: Duration = .seconds(30 * 60)
    private var lastHeartbeatSentAt: Date?

    private init() {
        isConfigured = SyncStore.shared.isConfigured
        peers = SyncStore.shared.peers

        MasterPasswordManager.shared.$isUnlocked
            .receive(on: RunLoop.main)
            .sink { [weak self] unlocked in
                if unlocked { self?.startEngine() } else { self?.stopEngine() }
            }
            .store(in: &cancellables)

        // Picks up entries written by a process that can't link SyncManager itself (currently
        // the Safari extension's password-creation/update path, see
        // DatabaseManager.markPendingSync). `.databaseDidOpen` fires both on an ordinary app
        // launch and whenever VaultAccessCoordinator reopens the vault after releasing it for
        // such a write — one hook covers both moments without needing them wired separately.
        NotificationCenter.default
            .publisher(for: .databaseDidOpen)
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in
                self?.enqueuePendingSyncFromOtherProcesses()
            }
            .store(in: &cancellables)

        // Mirror SyncStore.peers into the @Published property so views re-render
        NotificationCenter.default
            .publisher(for: UserDefaults.didChangeNotification)
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in
                guard let self else { return }
                let fresh = SyncStore.shared.peers
                if self.peers.map(\.id) != fresh.map(\.id)
                    || zip(self.peers, fresh).contains(where: {
                        $0.lastSeenAt != $1.lastSeenAt || $0.isSuspended != $1.isSuspended
                    }) {
                    self.peers = fresh
                }
            }
            .store(in: &cancellables)
    }

    // MARK: - Engine lifecycle

    func startEngine() {
        let store = SyncStore.shared
        guard store.isConfigured else {
            isConfigured = false
            peers = []
            return
        }
        isConfigured = true
        peers = store.peers
        stopEngine()
        // Enqueue any entries written by VaultTransferService while this vault was inactive
        let pendingIDs = SyncStore.shared.drainPendingTransferEntryIDs()
        if !pendingIDs.isEmpty, let entries = try? DatabaseManager.shared.fetchAllEntries() {
            for id in pendingIDs {
                if let entry = entries.first(where: { $0.id == id }) {
                    SyncOutbox.shared.enqueue(SyncOutbox.Item(
                        op: .upsert, entryId: entry.id,
                        updatedAt: entry.updatedAt, entry: entry))
                }
            }
        }
        pollTask  = Task { [weak self] in
            await self?.pollLoop()
            // Nil-out when the loop exits naturally so resumeIfNeeded() can detect it
            await MainActor.run { self?.pollTask = nil }
        }
        drainTask = Task { [weak self] in await self?.drainOutbox() }
    }

    func stopEngine() {
        pollTask?.cancel();  pollTask = nil
        drainTask?.cancel(); drainTask = nil
    }

    /// Triggers an immediate poll + outbox drain, restarting the engine.
    func forceSyncNow() {
        guard SyncStore.shared.isConfigured,
              MasterPasswordManager.shared.isUnlocked else { return }
        startEngine()
    }

    /// Restarts the engine only if the poll loop has stopped (e.g. after an iOS
    /// background suspension). Safe to call from scene-activation — no-op if healthy.
    func resumeIfNeeded() {
        guard SyncStore.shared.isConfigured,
              MasterPasswordManager.shared.isUnlocked else { return }
        if pollTask == nil { startEngine() }
    }

    /// Switches to 10-second polling for `duration` (default 3 min).
    /// Cancels any in-progress sleep so the next poll fires immediately.
    private func enableFastPoll(for duration: TimeInterval = 180) {
        fastPollDeadline = Date().addingTimeInterval(duration)
        // Cancel the current poll task (which may be sleeping) and restart it
        // so the first poll of the fast window happens right now.
        pollTask?.cancel()
        pollTask = Task { [weak self] in
            await self?.pollLoop()
            await MainActor.run { self?.pollTask = nil }
        }
    }

    // MARK: - Enqueue entry change and send immediately

    func enqueueUpsert(_ entry: Entry) {
        guard SyncStore.shared.isConfigured else { return }
        SyncOutbox.shared.enqueue(SyncOutbox.Item(op: .upsert, entryId: entry.id,
                                                   updatedAt: entry.updatedAt, entry: entry))
        kickDrain()
    }

    /// See the `.databaseDidOpen` observer in `init()`. Mirrors the `entriesMatching`-then-map
    /// shape `startEngine()` already uses for `SyncStore.drainPendingTransferEntryIDs()` — same
    /// idea, different (cross-process-reachable) source of pending IDs.
    private func enqueuePendingSyncFromOtherProcesses() {
        guard MasterPasswordManager.shared.isUnlocked, SyncStore.shared.isConfigured else { return }
        let ids = DatabaseManager.shared.drainPendingSyncEntryIDs()
        guard !ids.isEmpty, let entries = try? DatabaseManager.shared.fetchAllEntries() else { return }
        for id in ids {
            if let entry = entries.first(where: { $0.id == id }) {
                enqueueUpsert(entry)
            }
        }
    }

    func enqueueDelete(entryId: UUID) {
        guard SyncStore.shared.isConfigured else { return }
        SyncOutbox.shared.enqueue(SyncOutbox.Item(op: .delete, entryId: entryId,
                                                   updatedAt: Date(), entry: nil))
        kickDrain()
    }

    // MARK: - Create group (initiator)

    func createGroup(serverURL: String, deviceName: String) async throws -> String {
        isBusy = true
        defer { isBusy = false }

        let trimmedURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
        guard URL(string: trimmedURL) != nil else { throw SyncError.invalidServerURL }

        let client = makeClient(serverURL: trimmedURL)
        let registration = try await client.registerDevice(name: deviceName)

        let sgk      = SyncCrypto.generateSGK()
        let streamId = UUID().uuidString

        let store = SyncStore.shared
        store.serverURL  = trimmedURL
        store.deviceId   = registration.deviceId
        store.inboxId    = registration.inboxId
        store.deviceName = deviceName
        store.streamId   = streamId
        store.sendToken  = registration.sendToken
        store.recvToken  = registration.recvToken
        store.sgk        = sgk
        store.isEnabled  = true

        isConfigured = true
        startEngine()
        SecurityEventLogger.shared.log(.syncGroupCreated)

        return try encodePairingCode(serverURL: trimmedURL, streamId: streamId,
                                      sgk: sgk, inboxId: registration.inboxId,
                                      deviceName: deviceName)
    }

    // MARK: - Current pairing code (invite another device)

    /// Whether this device may currently mint a pairing code to invite another device. A device
    /// with an active sync subscription can always show it — inviting more devices is exactly
    /// what that subscription pays for. A device without one gets exactly one use per vault
    /// (Kotlin, which has no purchase flow at all, always falls into this branch): otherwise a
    /// single subscription could propagate a group through an unbounded chain of free devices
    /// each re-inviting further free devices, with no one but the original subscriber ever
    /// paying. Enforced client-side only, matching this whole feature's design goal of a blind,
    /// transparent relay with zero server-side gating — a modified/rebuilt client can bypass
    /// this, which is an accepted trade-off, not an oversight. Designed live 2026-07-30.
    var canShowPairingCode: Bool {
        if StoreManager.shared.isSyncPurchased { return true }
        return DatabaseManager.shared.getSetting("sync_one_shot_invite_used") != "1"
    }

    func currentPairingCode() throws -> String {
        guard canShowPairingCode else { throw SyncError.oneShotInviteAlreadyUsed }
        let store = SyncStore.shared
        guard let sgk = store.sgk else { throw SyncError.invalidPairingCode }
        let code = try encodePairingCode(serverURL: store.serverURL, streamId: store.streamId,
                                          sgk: sgk, inboxId: store.inboxId,
                                          deviceName: store.deviceName)
        if !StoreManager.shared.isSyncPurchased {
            DatabaseManager.shared.setSetting("sync_one_shot_invite_used", value: "1")
        }
        return code
    }

    // MARK: - Join group (joiner)

    func joinGroup(pairingCodeString: String, deviceName: String) async throws {
        isBusy = true
        defer { isBusy = false }

        let trimmed = pairingCodeString.trimmingCharacters(in: .whitespacesAndNewlines)
        guard let pairingData = base64urlDecode(trimmed) else { throw SyncError.invalidPairingCode }
        let pairing = try JSONDecoder().decode(PairingCode.self, from: pairingData)

        guard URL(string: pairing.serverURL) != nil else { throw SyncError.invalidServerURL }
        guard let sgk = base64urlDecode(pairing.sgk), sgk.count == 32 else {
            throw SyncError.invalidPairingCode
        }

        let client = makeClient(serverURL: pairing.serverURL)
        let registration = try await client.registerDevice(name: deviceName)

        let store = SyncStore.shared
        store.serverURL  = pairing.serverURL
        store.deviceId   = registration.deviceId
        store.inboxId    = registration.inboxId
        store.deviceName = deviceName
        store.streamId   = pairing.streamId
        store.sendToken  = registration.sendToken
        store.recvToken  = registration.recvToken
        store.sgk        = sgk
        store.isEnabled  = true

        let initiator = SyncPeer(inboxId: pairing.inboxId,
                                 deviceName: pairing.deviceName, addedAt: Date())
        var peers = store.peers
        if !peers.contains(where: { $0.inboxId == pairing.inboxId }) { peers.append(initiator) }
        store.peers = peers

        isConfigured = true
        startEngine()
        enableFastPoll()     // 10 s polling for 3 min while bulk sync messages are in flight
        SecurityEventLogger.shared.log(.syncGroupJoined)

        // Announce ourselves so the initiator learns our inbox
        try await sendHello(client: client, store: store)

        // Push all our local entries to the initiator (and any other existing peers)
        Task { await self.bulkSyncAllEntries(recipientInboxIds: peers.map { $0.inboxId },
                                              client: client, store: store) }
    }

    // MARK: - Revoke a peer device

    /// Removes `peer` from the local group immediately and unconditionally, then makes a
    /// best-effort attempt to broadcast a `deviceRevoke` (so the target can self-wipe and
    /// other peers learn too) and rotate the Sync Group Key. The local removal is what
    /// actually enforces the revoke from this device's own point of view — every outbound
    /// path (outbox drain, hello/heartbeat/bulk-sync broadcasts) sends only to `store.peers`,
    /// so once `peer` is gone from that list, this device will never again queue anything
    /// addressed to its inbox ID, regardless of whether the relay is reachable right now.
    ///
    /// A prior version only removed the peer after a successful relay send — so if the
    /// device was offline/the relay unreachable, the "revoked" device stayed a full member
    /// indefinitely (or until the user retried), which defeats the point of revoking it.
    /// The broadcast/rotation below are still attempted and still matter (they're what lets
    /// OTHER devices and the target itself learn about the revoke), but their failure is now
    /// non-fatal to the local suppression the user actually needs.
    func revokeDevice(peer: SyncPeer) async {
        isBusy = true
        defer { isBusy = false }

        let store = SyncStore.shared

        // Capture the full recipient list (including the target) before mutating store.peers —
        // the broadcast below still needs to reach the target itself so it can self-wipe.
        let allRecipients = store.peers.map { $0.inboxId }

        store.peers = store.peers.filter { $0.inboxId != peer.inboxId }
        store.markRevoked(inboxId: peer.inboxId)
        SecurityEventLogger.shared.log(.deviceRevoked, detail: peer.deviceName)

        guard let sendToken = store.sendToken, let sgk = store.sgk,
              !allRecipients.isEmpty else { return }

        let client = makeClient(serverURL: store.serverURL)

        do {
            let payload = SyncPayload(
                eventId: UUID().uuidString,
                senderDeviceId: store.deviceId,
                senderCounter: store.nextCounter(),
                op: .deviceRevoke,
                inboxId: peer.inboxId,       // target's inbox — receivers remove this device
                deviceName: peer.deviceName
            )
            let payloadData = try JSONEncoder().encode(payload)
            let messageId   = UUID().uuidString

            let ciphertext = try SyncCrypto.encrypt(
                payload: payloadData, sgk: sgk, messageId: messageId,
                streamId: store.streamId, senderDeviceId: store.deviceId,
                recipientInboxIds: allRecipients, senderCounter: payload.senderCounter
            )
            let msg = OutboundRelayMessage(
                messageId: messageId, streamId: store.streamId,
                senderDeviceId: store.deviceId, recipientInboxIds: allRecipients,
                ciphertext: ciphertext, encryptedIdentifier: nil,
                ttlSeconds: 30 * 24 * 3600,  // 30-day TTL: offline device gets it when it reconnects
                cipherVersion: 1
            )
            _ = try await client.sendMessage(msg, sendToken: sendToken)
        } catch {
            print("[Sync] device_revoke broadcast failed: \(error) — revoked locally regardless; other peers/the target won't learn until this succeeds (retry by revoking again once reachable)")
        }

        // Rotate the SGK so the revoked device's copy of the old key can never decrypt
        // future traffic, regardless of whether the broadcast above succeeded — this
        // cryptographic step matters even more than the explicit revoke message when the
        // network is flaky, since it's what actually locks the old device out.
        let remaining = store.peers.map { $0.inboxId }
        if !remaining.isEmpty {
            do {
                try await sendKeyRotation(oldSGK: sgk, recipients: remaining,
                                          client: client, store: store, sendToken: sendToken)
            } catch {
                print("[Sync] key rotation after revoke failed: \(error) — group keeps the old SGK until next revoke")
            }
        }
    }

    // MARK: - Deregister a vault's sync device (used when deleting the vault itself)

    /// Best-effort deregisters this device's own sync registration for the given vault
    /// and clears its local sync state (streamId, SGK, tokens, peers, cursor). Used by
    /// `VaultRegistry.deleteVault` so a deleted vault doesn't leave a device registration
    /// (and its send/recv tokens) live on the relay indefinitely — previously nothing
    /// called this, so the registration only went away via the relay's own idle-token
    /// expiry (6 months, see the API spec) rather than immediately on deletion.
    ///
    /// Does not send anything to peers (unlike `revokeDevice`) — from the group's point of
    /// view this device just goes silent, same as an uninstall; the vault no longer exists
    /// locally so there is nothing left to keep a sync group cohesive for.
    func deregisterSyncDevice(forVaultID vaultID: UUID) async {
        let store = SyncStore(vaultID: vaultID)
        if let sendToken = store.sendToken, !store.serverURL.isEmpty {
            do {
                try await makeClient(serverURL: store.serverURL).deregisterDevice(sendToken: sendToken)
            } catch {
                print("[Sync] deregister on vault delete failed: \(error) — clearing local state anyway")
            }
        }
        store.clearAll()
    }

    // MARK: - Key rotation (invalidates a revoked device's copy of the SGK)

    /// Generates a fresh SGK, announces it to `recipients` while still encrypted under
    /// `oldSGK`, then — only once the send succeeds — switches this device over locally.
    private func sendKeyRotation(oldSGK: Data, recipients: [String],
                                  client: RelayAPIClient, store: SyncStore,
                                  sendToken: String) async throws {
        let newSGK = SyncCrypto.generateSGK()

        let payload = SyncPayload(
            eventId: UUID().uuidString,
            senderDeviceId: store.deviceId,
            senderCounter: store.nextCounter(),
            op: .keyRotate,
            newSgk: base64urlEncode(newSGK)
        )
        let payloadData = try JSONEncoder().encode(payload)
        let messageId   = UUID().uuidString

        let ciphertext = try SyncCrypto.encrypt(
            payload: payloadData, sgk: oldSGK, messageId: messageId,
            streamId: store.streamId, senderDeviceId: store.deviceId,
            recipientInboxIds: recipients, senderCounter: payload.senderCounter
        )
        let msg = OutboundRelayMessage(
            messageId: messageId, streamId: store.streamId,
            senderDeviceId: store.deviceId, recipientInboxIds: recipients,
            ciphertext: ciphertext, encryptedIdentifier: nil,
            ttlSeconds: 30 * 24 * 3600,   // 30 days: offline peers must still learn the new key
            cipherVersion: 1
        )
        _ = try await client.sendMessage(msg, sendToken: sendToken)

        // Only switch our own key once the announcement is durably queued on the relay —
        // otherwise a crash between generating and sending would strand the group split
        // between the old and new key with no one holding both.
        store.sgk = newSGK
        SecurityEventLogger.shared.log(.syncKeyRotated)
    }

    // MARK: - Disable sync

    func disableSync() async throws {
        isBusy = true
        defer { isBusy = false }
        stopEngine()

        let store = SyncStore.shared
        // Notify peers before deregistering so they can remove us from their list
        if store.isConfigured, let sendToken = store.sendToken, !store.peers.isEmpty {
            let client = makeClient(serverURL: store.serverURL)
            try? await sendLeave(client: client, store: store, sendToken: sendToken)
        }
        if !store.serverURL.isEmpty, let token = store.sendToken {
            try? await makeClient(serverURL: store.serverURL).deregisterDevice(sendToken: token)
        }
        store.clearAll()
        isConfigured = false
    }

    // MARK: - Poll loop (short-poll: connect → check → disconnect → sleep)
    //
    // wait_ms=0 so the server responds immediately; we sleep locally between
    // polls. This avoids holding a persistent connection per device open on
    // the relay server.

    private func pollLoop() async {
        let store = SyncStore.shared
        guard let recvToken = store.recvToken, !store.inboxId.isEmpty else { return }
        let expectedVaultID = store.ownerVaultID   // guard against vault-switch races
        let client = makeClient(serverURL: store.serverURL)

        #if os(iOS)
        print("[Sync] poll loop started (iOS, interval \(pollInterval))")
        #else
        print("[Sync] poll loop started (macOS, interval \(pollInterval))")
        #endif

        while !Task.isCancelled {
            do {
                let cursor = store.cursor.isEmpty ? nil : store.cursor
                let response = try await client.pollMessages(
                    recvToken: recvToken,
                    inboxId: store.inboxId,
                    after: cursor,
                    limit: 100,
                    waitMs: 0           // ← no held connection
                )

                if !response.messages.isEmpty {
                    let allApplied = await processMessages(response.messages, store: store, client: client, expectedVaultID: expectedVaultID)
                    // Only advance the cursor once all messages in the batch are durably
                    // applied and ACKed. If any were deferred (DB closed), keep the old
                    // cursor so the relay returns them again on the next poll.
                    if allApplied, !response.nextCursor.isEmpty {
                        store.cursor = response.nextCursor
                    }
                    // Full batch → more messages likely queued; stay in fast mode
                    fastPollOnFullBatch = (response.messages.count >= 100)
                } else {
                    fastPollOnFullBatch = false   // inbox empty, clear count-based fast poll
                }

                // Expire deadline-based fast poll
                if let deadline = fastPollDeadline, Date() >= deadline {
                    fastPollDeadline = nil
                }

                if lastHeartbeatSentAt.map({ Date().timeIntervalSince($0) >= Double(heartbeatInterval.components.seconds) }) ?? true {
                    lastHeartbeatSentAt = Date()
                    try? await sendHeartbeat(client: client, store: store)
                }

                let interval = isInFastPollMode ? fastPollInterval : pollInterval
                try await Task.sleep(for: interval)

            } catch is CancellationError {
                break
            } catch RelayError.tokenExpired, RelayError.unauthorized {
                print("[Sync] poll stopped: token expired or unauthorized — engine will restart on next unlock")
                break
            } catch {
                guard !Task.isCancelled else { break }
                print("[Sync] poll error: \(error) — retrying in 30s")
                try? await Task.sleep(for: .seconds(30))
            }
        }
    }

    // MARK: - Outbox drain

    private func drainOutbox() async {
        await sendOutboxItems()
    }

    private func kickDrain() {
        drainTask?.cancel()
        drainTask = Task { [weak self] in
            // Small debounce so rapid consecutive saves are batched
            try? await Task.sleep(for: .milliseconds(100))
            await self?.sendOutboxItems()
        }
    }

    /// Drains the shared outbox and sends each item to all current peers,
    /// respecting the relay's 20 req/s rate limit.
    private func sendOutboxItems() async {
        let store = SyncStore.shared
        guard let sendToken = store.sendToken,
              let sgk = store.sgk,
              !store.peers.isEmpty else { return }

        let items = SyncOutbox.shared.drain()
        guard !items.isEmpty else { return }

        let client     = makeClient(serverURL: store.serverURL)
        let recipients = store.peers.filter { !$0.isSuspended }.map { $0.inboxId }
        guard !recipients.isEmpty else { return }

        await sendItems(items, toInboxIds: recipients,
                        client: client, store: store,
                        sendToken: sendToken, sgk: sgk)
    }

    // MARK: - Bulk sync (send all DB entries to specific inboxes)

    /// Fetches every entry in the local vault and sends upsert events to
    /// `recipientInboxIds` only. Used after pairing so both sides converge
    /// without flooding unrelated peers. Publishes `syncProgress` for the duration so a
    /// pairing view can show "sending 12/340…" instead of appearing to hang — a first
    /// bulk resync of a large vault can take a while at the rate-limited send pace.
    private func bulkSyncAllEntries(recipientInboxIds: [String],
                                     client: RelayAPIClient,
                                     store: SyncStore) async {
        guard let sendToken = store.sendToken,
              let sgk       = store.sgk,
              !recipientInboxIds.isEmpty,
              VaultRegistry.shared.activeVaultID == store.ownerVaultID,
              let entries   = try? DatabaseManager.shared.fetchAllEntries(),
              !entries.isEmpty else { return }

        let items = entries.map {
            SyncOutbox.Item(op: .upsert, entryId: $0.id, updatedAt: $0.updatedAt, entry: $0)
        }
        syncProgress = SyncProgress(sent: 0, total: items.count, isReceiving: false)
        defer { syncProgress = nil }
        await sendItems(items, toInboxIds: recipientInboxIds,
                        client: client, store: store,
                        sendToken: sendToken, sgk: sgk,
                        onItemSent: { [weak self] sentCount in
            guard let self, let progress = self.syncProgress else { return }
            self.syncProgress = SyncProgress(sent: sentCount, total: progress.total, isReceiving: false)
        })
    }

    // MARK: - Shared send primitive

    /// Builds, encrypts, and sends each outbox item to the given inboxes.
    /// Inserts a `sendDelay` between messages to stay under the rate limit.
    /// Items that fail to send are re-enqueued in the shared outbox.
    /// `onItemSent`, if provided, is called with the running count of items processed so far
    /// (sent or failed) — used only by `bulkSyncAllEntries` to drive `syncProgress`; routine
    /// outbox drains (typically 1-2 items) pass nil and show no progress UI.
    private func sendItems(_ items: [SyncOutbox.Item],
                            toInboxIds recipients: [String],
                            client: RelayAPIClient,
                            store: SyncStore,
                            sendToken: String,
                            sgk: Data,
                            onItemSent: ((Int) -> Void)? = nil) async {
        let iso = ISO8601DateFormatter()
        var processedCount = 0

        for item in items {
            guard !Task.isCancelled else { break }
            defer {
                processedCount += 1
                onItemSent?(processedCount)
            }
            do {
                var payload = SyncPayload(
                    eventId: UUID().uuidString,
                    senderDeviceId: store.deviceId,
                    senderCounter: store.nextCounter(),
                    op: item.op,
                    entryId: item.entryId.uuidString,
                    updatedAt: iso.string(from: item.updatedAt)
                )
                payload.senderDeviceName = store.deviceName.isEmpty ? nil : store.deviceName
                payload.inboxId = store.inboxId   // authoritative identity for last-seen matching, see SyncPayload.inboxId

                if let entry = item.entry,
                   let key = MasterPasswordManager.shared.key,
                   let vaultID = DatabaseManager.shared.vaultID {
                    payload.name      = entry.name
                    payload.username  = entry.username
                    payload.website   = entry.website        // first URL, backward compat
                    payload.websites  = entry.websites.isEmpty ? nil : entry.websites
                    payload.entryType = entry.entryType.rawValue
                    payload.isFavorite = entry.isFavorite
                    payload.createdAt = iso.string(from: entry.createdAt)
                    payload.deletedAt = entry.deletedAt.map { iso.string(from: $0) }

                    func dec(_ blob: String?, field: String) -> String? {
                        guard let b = blob else { return nil }
                        return try? CryptoManager.decrypt(
                            b, key: key,
                            aad: CryptoManager.aad(vaultID: vaultID,
                                                   entryID: entry.id,
                                                   fieldName: field))
                    }
                    payload.password         = dec(entry.encryptedPassword,         field: "password")
                    payload.previousPassword = dec(entry.encryptedPreviousPassword, field: "previous_password")
                    payload.note             = dec(entry.encryptedNote,              field: "note")
                    payload.cardNumber       = dec(entry.encryptedCardNumber,        field: "card_number")
                    payload.cardExpiry       = dec(entry.encryptedCardExpiry,        field: "card_expiry")
                    payload.cardCVV          = dec(entry.encryptedCardCVV,           field: "card_cvv")
                    payload.cardPin          = dec(entry.encryptedCardPin,           field: "card_pin")
                    payload.totpSecret            = dec(entry.encryptedTOTPSecret,             field: "totp_secret")
                    payload.passkeyRelyingPartyId = entry.passkeyRelyingPartyId
                    payload.passkeyCredentialId   = dec(entry.encryptedPasskeyCredentialId, field: "passkey_credential_id")
                    payload.passkeyUserHandle     = dec(entry.encryptedPasskeyUserHandle,   field: "passkey_user_handle")

                    // Send the complete history, not just this change — a device that joins the
                    // group later, or was offline while several password changes happened, would
                    // otherwise never learn about any change but the most recent one it happened
                    // to receive live. Well within the relay's 262144-byte message cap even for a
                    // long history (each entry is a few hundred bytes at most).
                    if let history = try? DatabaseManager.shared.fetchPasswordHistory(for: entry.id) {
                        payload.passwordHistory = history.compactMap { h in
                            guard let pwd = dec(h.encryptedPassword, field: "password") else { return nil }
                            return SyncPasswordHistoryItem(
                                id: h.id.uuidString,
                                changedAt: iso.string(from: h.changedAt),
                                password: pwd)
                        }
                    }
                }

                let payloadData = try JSONEncoder().encode(payload)
                let messageId   = UUID().uuidString

                let ciphertext = try SyncCrypto.encrypt(
                    payload: payloadData,
                    sgk: sgk,
                    messageId: messageId,
                    streamId: store.streamId,
                    senderDeviceId: store.deviceId,
                    recipientInboxIds: recipients,
                    senderCounter: payload.senderCounter
                )

                let msg = OutboundRelayMessage(
                    messageId: messageId, streamId: store.streamId,
                    senderDeviceId: store.deviceId, recipientInboxIds: recipients,
                    ciphertext: ciphertext, encryptedIdentifier: nil,
                    ttlSeconds: nil, cipherVersion: 1
                )
                _ = try await client.sendMessage(msg, sendToken: sendToken)

                // Rate-limit: stay under 20 req/s
                try await Task.sleep(for: sendDelay)

            } catch is CancellationError {
                SyncOutbox.shared.enqueue(item)
                break
            } catch {
                print("[Sync] send error for \(item.entryId): \(error)")
                SyncOutbox.shared.enqueue(item)   // retry later
            }
        }
    }

    // MARK: - Message processing

    /// Returns true when every message in the batch was successfully applied and ACKed.
    /// Returns false if any message was deferred (DB unavailable) — callers must NOT
    /// advance the cursor in that case, so the relay returns the batch again next poll.
    private func processMessages(_ messages: [InboundRelayMessage],
                                  store: SyncStore, client: RelayAPIClient,
                                  expectedVaultID: UUID?) async -> Bool {
        var toAck:      [String] = []
        var allApplied: Bool     = true   // flips to false on first deferral

        // Only surface a progress bar for batches worth showing one for (e.g. the
        // flood of entries a freshly-paired device receives) — a routine 1-message
        // poll tick would just flash the UI.
        let showProgress = messages.count > 1
        var processedCount = 0
        if showProgress {
            syncProgress = SyncProgress(sent: 0, total: messages.count, isReceiving: true)
        }
        defer { if showProgress { syncProgress = nil } }

        for msg in messages {
            defer {
                processedCount += 1
                if showProgress {
                    syncProgress = SyncProgress(sent: processedCount, total: messages.count, isReceiving: true)
                }
            }
            // Guard against vault-switch race: if the active vault changed while we were
            // mid-flight, discard these messages — the new vault's poll loop will handle its own.
            guard VaultRegistry.shared.activeVaultID == expectedVaultID else {
                print("[Sync] vault switched mid-poll, discarding \(messages.count) messages")
                return false
            }

            if store.hasApplied(eventId: msg.messageId) {
                toAck.append(msg.messageId)
                updateLastSeen(senderDeviceId: msg.senderDeviceId, senderInboxId: nil, senderDeviceName: nil, store: store)
                continue
            }
            guard let sgk = store.sgk else { allApplied = false; continue }

            do {
                let payloadData = try SyncCrypto.decrypt(
                    ciphertext: msg.ciphertext,
                    sgk: sgk,
                    messageId: msg.messageId,
                    streamId: msg.streamId,
                    senderDeviceId: msg.senderDeviceId,
                    recipientInboxIds: msg.recipientInboxIds,
                    senderCounter: 0
                )
                let payload = try JSONDecoder().decode(SyncPayload.self, from: payloadData)

                // Allow-list, not a deny-list: `inboxId` only reliably means "the sender's own
                // inbox" for heartbeat/upsert/delete (the three ops it was added to explicitly).
                // device_revoke repurposes it as the TARGET being revoked; device_hello can be a
                // *relayed* introduction (sendPeerIntroduction) where it names a third peer being
                // introduced, not whoever actually relayed the message — trusting it there would
                // credit the wrong peer's last-seen. device_leave happens to be honest too, but
                // there's no need to special-case it in.
                let senderInboxId: String?
                switch payload.op {
                case .heartbeat, .upsert, .delete: senderInboxId = payload.inboxId
                default: senderInboxId = nil
                }

                if store.hasApplied(eventId: payload.eventId) {
                    toAck.append(msg.messageId)
                    updateLastSeen(senderDeviceId: msg.senderDeviceId, senderInboxId: senderInboxId, senderDeviceName: payload.senderDeviceName, store: store)
                    continue
                }

                print("[Sync] applying op=\(payload.op) senderDeviceId=\(msg.senderDeviceId) senderInboxId=\(senderInboxId ?? "nil") senderDeviceName=\(payload.senderDeviceName ?? "nil")")
                let applied = await applyPayload(payload, store: store,
                                           senderInboxHint: msg.senderDeviceId,
                                           client: client, messageId: msg.messageId)
                guard applied else {
                    // DB unavailable — leave message in inbox for retry on next poll
                    print("[Sync] apply deferred (DB not ready), will retry: \(msg.messageId)")
                    allApplied = false
                    continue
                }
                store.markApplied(eventId: payload.eventId)
                store.markApplied(eventId: msg.messageId)
                toAck.append(msg.messageId)
                updateLastSeen(senderDeviceId: msg.senderDeviceId, senderInboxId: senderInboxId, senderDeviceName: payload.senderDeviceName, store: store)
            } catch {
                // Decrypt errors are permanent failures for THIS specific ciphertext/key
                // pairing — AEAD is deterministic, so retrying with the same (current) SGK
                // will never succeed. The most common real-world cause is a benign send-order
                // race with our own SGK rotation (see the 2026-07-18 note above sendItems):
                // this message was encrypted under an SGK we've since discarded. Ack it so it
                // doesn't linger on the relay forever un-acked and un-revisited (the cursor
                // still advances past the whole batch below), and log it so a lost entry
                // update is at least visible instead of silently vanishing.
                print("[Sync] decrypt/apply error (message permanently undecipherable, acking to clear it): \(error)")
                SecurityEventLogger.shared.log(.syncMessageUndecryptable, detail: msg.messageId)
                toAck.append(msg.messageId)
            }
        }

        if !toAck.isEmpty, let recvToken = store.recvToken {
            try? await client.ackMessages(recvToken: recvToken,
                                          inboxId: store.inboxId, messageIds: toAck)
        }
        return allApplied
    }

    /// Updates the sending peer's `lastSeenAt`, matched by relay device ID (falling back to
    /// device name for peers paired before `relayDeviceId` was tracked). Called on *every*
    /// message that gets ACKed — including the two dedupe-and-skip paths in `processMessages`
    /// (a message already marked applied, e.g. a redelivery after a prior ACK attempt failed)
    /// — not just freshly-applied ones. Those dedupe paths still prove the sender is alive and
    /// reachable right now; skipping the update there (as a prior version did) let a peer show
    /// a stale "last seen" days in the past even while its redelivered messages were being
    /// ACKed successfully in the present. `senderDeviceName` is unavailable for the very first
    /// dedupe check (before the ciphertext is decrypted) — that's fine, matching by relay
    /// device ID alone is enough for any peer that already has one recorded.
    /// `senderInboxId` (added 2026-07-27) is the authoritative match — it's the same identifier
    /// that already, correctly, routes every message, so unlike `relayDeviceId` it can't drift.
    /// `relayDeviceId`, then `senderDeviceName`, are fallbacks only for messages that predate
    /// `inboxId` being carried on heartbeat/upsert/delete (older in-flight/queued messages, or
    /// a peer still running an older build) — matching by name risks a false match if two peers
    /// share a display name, which `inboxId` can't, since it's relay-assigned and unique.
    private func updateLastSeen(senderDeviceId: String, senderInboxId: String?, senderDeviceName: String?, store: SyncStore) {
        var peerIdx: Int? = nil
        if let inboxId = senderInboxId {
            peerIdx = store.peers.firstIndex(where: { $0.inboxId == inboxId })
        }
        if peerIdx == nil {
            peerIdx = store.peers.firstIndex(where: { $0.relayDeviceId == senderDeviceId })
        }
        if peerIdx == nil, let senderName = senderDeviceName {
            peerIdx = store.peers.firstIndex(where: { $0.deviceName == senderName })
        }
        if let idx = peerIdx {
            if store.peers[idx].relayDeviceId != senderDeviceId {
                store.peers[idx].relayDeviceId = senderDeviceId
            }
            store.peers[idx].lastSeenAt = Date()
        } else {
            // Silent no-op otherwise — logged so a live test can actually show *why* a peer's
            // last-seen isn't advancing.
            print("[Sync] last-seen update skipped: no peer matched senderDeviceId=\(senderDeviceId) senderInboxId=\(senderInboxId ?? "nil") senderDeviceName=\(senderDeviceName ?? "nil") — known peers: \(store.peers.map { ($0.deviceName, $0.inboxId, $0.relayDeviceId ?? "nil") })")
        }
    }

    // MARK: - Apply payload to local DB
    //
    // Returns true only when the payload was durably applied (or was a no-op
    // that is safe to consider done). Returns false when the DB is unavailable
    // or a write error occurred — the caller must NOT ACK in that case.

    @discardableResult
    private func applyPayload(_ payload: SyncPayload, store: SyncStore,
                               senderInboxHint: String, client: RelayAPIClient,
                               messageId: String) async -> Bool {
        switch payload.op {

        case .deviceLeave:
            guard let leavingInboxId = payload.inboxId else { return false }
            let leavingName = payload.deviceName
                ?? store.peers.first(where: { $0.inboxId == leavingInboxId })?.deviceName
            store.peers = store.peers.filter { $0.inboxId != leavingInboxId }
            SecurityEventLogger.shared.log(.peerLeftSyncGroup, detail: leavingName)
            return true

        case .deviceRevoke:
            guard let targetInboxId = payload.inboxId else { return false }
            // If WE are the target: wipe and self-deregister. Awaited (not a detached Task)
            // so the message isn't ACKed to the relay — and no later message in the same
            // batch (e.g. a key_rotate) is processed — until the local wipe has actually
            // completed. Otherwise a kill/background right after the ACK could leave a
            // revoked device with a stale, still-decryptable local vault copy.
            if targetInboxId == store.inboxId {
                await performSelfRevocation(triggeringMessageId: messageId, client: client)
            }
            // All devices (including the target itself) remove it from their peers list
            store.peers = store.peers.filter { $0.inboxId != targetInboxId }
            // Tombstone so a stale/late .deviceHello about this peer — relayed by some other
            // peer that queued it before learning of the revocation — can't silently re-add
            // it to our peer list after the fact. See SyncStore.revokedInboxIds.
            store.markRevoked(inboxId: targetInboxId)
            return true

        case .keyRotate:
            // Sent by the device that performed a revocation, still encrypted under the
            // OLD SGK (which is how we're able to decrypt this message at all). Switching
            // our stored SGK to the new one means any subsequent message encrypted under
            // the old key (e.g. from a revoked device that never got the memo) will fail
            // AEAD verification and be dropped, exactly as intended.
            guard let newSgkB64 = payload.newSgk, let newSgk = base64urlDecode(newSgkB64),
                  newSgk.count == 32 else { return false }
            store.sgk = newSgk
            SecurityEventLogger.shared.log(.syncKeyRotated, fromDevice: payload.senderDeviceName)
            return true

        case .heartbeat:
            // Carries no data — its only purpose is to be a message the sender's peers
            // receive, so processMessages' generic lastSeenAt update below fires even
            // when this device has nothing else to say (e.g. it only received entries,
            // never sent any, and would otherwise look permanently stale to its peers).
            return true

        case .deviceHello:
            guard let newInboxId    = payload.inboxId,
                  let newDeviceName = payload.deviceName else { return false }
            // Refuse to (re)learn about a device we've revoked — a legitimate remaining
            // peer can still queue/relay an introduction about it if that peer hadn't yet
            // learned of the revocation when it sent it (see SyncStore.revokedInboxIds).
            guard !store.revokedInboxIds.contains(newInboxId) else { return true }
            var peers = store.peers
            if let idx = peers.firstIndex(where: { $0.inboxId == newInboxId }) {
                // Already known — update relayDeviceId in case it was missing
                peers[idx].relayDeviceId = payload.senderDeviceId
                store.peers = peers
                return true
            }
            let newPeer = SyncPeer(inboxId: newInboxId, deviceName: newDeviceName,
                                    addedAt: Date(), relayDeviceId: payload.senderDeviceId)
            // Snapshot of peers we already knew about, before adding the new one — these
            // are exactly the devices that need introducing to (and from) the newcomer.
            let alreadyKnownPeers = peers
            peers.append(newPeer)
            store.peers = peers
            enableFastPoll()

            // Only bulk-sync our vault to the newcomer when THEY greeted us directly (wire
            // sender matches the payload's own identity) — not when we're merely learning
            // about them via a relayed introduction from a third peer (see below). Without
            // this guard, every already-converged existing peer that hears about the
            // newcomer via relay would redundantly resend its own full copy of the exact
            // same entries the newcomer already got from the peer it greeted directly —
            // multiplying relay traffic by the group's existing device count for no benefit,
            // since those devices' vaults already agree.
            let isDirectHello = (senderInboxHint == payload.senderDeviceId)
            if isDirectHello {
                Task { await self.bulkSyncAllEntries(recipientInboxIds: [newInboxId],
                                                      client: client, store: store) }
            }

            // Full-mesh bootstrap: a device that joins via a pairing code only ever learns
            // about (and sends hello to) the code's originator — so without this, only the
            // originator ends up with a complete peer list, and every other device only
            // knows the originator. That breaks entry sync between non-originator devices
            // (each device's outbox only sends to its own peer list) and prevents revoking
            // one non-originator device from another. Whenever THIS device learns of a
            // genuinely new peer, relay introductions both ways so the mesh converges — this
            // part always runs (peer-list convergence is cheap and idempotent), independent
            // of the bulk-sync guard above, which only controls the expensive entry resend.
            if let sendToken = store.sendToken, !alreadyKnownPeers.isEmpty {
                Task {
                    for other in alreadyKnownPeers {
                        try? await self.sendPeerIntroduction(newPeer, toInboxId: other.inboxId,
                                                              client: client, store: store, sendToken: sendToken)
                        try? await self.sendPeerIntroduction(other, toInboxId: newInboxId,
                                                              client: client, store: store, sendToken: sendToken)
                    }
                }
            }
            return true

        case .upsert:
            let db = DatabaseManager.shared
            guard db.isOpen,
                  let entryIdStr = payload.entryId,
                  let entryId    = UUID(uuidString: entryIdStr),
                  let key        = MasterPasswordManager.shared.key,
                  let vaultID    = db.vaultID else { return false }

            let iso       = ISO8601DateFormatter()
            let updatedAt = payload.updatedAt.flatMap { iso.date(from: $0) } ?? Date()
            let createdAt = payload.createdAt.flatMap { iso.date(from: $0) } ?? Date()
            let entryType = payload.entryType.flatMap { EntryType(rawValue: $0) } ?? .login

            func enc(_ val: String?, field: String) -> String? {
                guard let v = val, !v.isEmpty else { return nil }
                return try? CryptoManager.encrypt(
                    v, key: key,
                    aad: CryptoManager.aad(vaultID: vaultID, entryID: entryId, fieldName: field))
            }

            let sender = payload.senderDeviceName
            do {
                // Resolve websites: prefer the new array, fall back to legacy single field
                let syncWebsites: [String] = payload.websites
                    ?? payload.website.map { [$0] }
                    ?? []

                if let existing = try db.fetchAllEntries().first(where: { $0.id == entryId }) {
                    guard updatedAt > existing.updatedAt else { return true }    // stale, skip
                    var updated = existing
                    updated.name                      = payload.name ?? existing.name
                    updated.username                  = payload.username
                    updated.websites                  = syncWebsites
                    updated.encryptedPassword         = enc(payload.password,         field: "password")
                    updated.encryptedPreviousPassword = enc(payload.previousPassword, field: "previous_password")
                    updated.encryptedNote             = enc(payload.note,             field: "note")
                    updated.encryptedCardNumber       = enc(payload.cardNumber,       field: "card_number")
                    updated.encryptedCardExpiry       = enc(payload.cardExpiry,       field: "card_expiry")
                    updated.encryptedCardCVV          = enc(payload.cardCVV,          field: "card_cvv")
                    updated.encryptedCardPin          = enc(payload.cardPin,          field: "card_pin")
                    updated.encryptedTOTPSecret         = enc(payload.totpSecret,             field: "totp_secret")
                    updated.passkeyRelyingPartyId       = payload.passkeyRelyingPartyId
                    updated.encryptedPasskeyCredentialId = enc(payload.passkeyCredentialId,  field: "passkey_credential_id")
                    updated.encryptedPasskeyUserHandle   = enc(payload.passkeyUserHandle,    field: "passkey_user_handle")
                    updated.entryType                   = entryType
                    if let fav = payload.isFavorite { updated.isFavorite = fav }
                    updated.deletedAt                 = payload.deletedAt.flatMap { iso.date(from: $0) }
                    updated.updatedAt                 = updatedAt
                    try db.updateEntry(updated)
                    SecurityEventLogger.shared.log(.entryUpdated,
                        detail: updated.name, fromDevice: sender)
                    PasswordHealthViewModel.runInBackground()
                } else {
                    let entry = Entry(
                        id: entryId,
                        name: payload.name ?? "(sans nom)",
                        username: payload.username,
                        websites: syncWebsites,
                        encryptedPassword:         enc(payload.password,         field: "password"),
                        encryptedPreviousPassword: enc(payload.previousPassword, field: "previous_password"),
                        encryptedNote:             enc(payload.note,             field: "note"),
                        encryptedCardNumber:       enc(payload.cardNumber,       field: "card_number"),
                        encryptedCardExpiry:       enc(payload.cardExpiry,       field: "card_expiry"),
                        encryptedCardCVV:          enc(payload.cardCVV,          field: "card_cvv"),
                        encryptedCardPin:          enc(payload.cardPin,          field: "card_pin"),
                        encryptedTOTPSecret:          enc(payload.totpSecret,            field: "totp_secret"),
                        passkeyRelyingPartyId:        payload.passkeyRelyingPartyId,
                        encryptedPasskeyCredentialId: enc(payload.passkeyCredentialId, field: "passkey_credential_id"),
                        encryptedPasskeyUserHandle:   enc(payload.passkeyUserHandle,   field: "passkey_user_handle"),
                        entryType: entryType,
                        isFavorite: payload.isFavorite ?? false,
                        createdAt: createdAt,
                        updatedAt: updatedAt,
                        deletedAt: payload.deletedAt.flatMap { iso.date(from: $0) }
                    )
                    try db.insertEntry(entry)
                    SecurityEventLogger.shared.log(.entryCreated,
                        detail: entry.name, fromDevice: sender)
                    PasswordHealthViewModel.runInBackground()
                }

                // Merge the sender's full history rather than just this one change, so a device
                // that joined the group late (or was offline for several password changes) still
                // ends up with every entry another device already has. INSERT OR IGNORE on the id
                // (see DatabaseManager.insertPasswordHistory) makes this idempotent against the
                // same message being re-applied.
                if let historyItems = payload.passwordHistory {
                    for item in historyItems {
                        guard let historyId = UUID(uuidString: item.id),
                              let encPwd = enc(item.password, field: "password")
                        else { continue }
                        let changedAt = iso.date(from: item.changedAt) ?? Date()
                        try? db.insertPasswordHistory(PasswordHistoryEntry(
                            id: historyId, entryId: entryId,
                            encryptedPassword: encPwd, changedAt: changedAt))
                    }
                }
            } catch {
                print("[Sync] upsert failed (DB may be closed): \(error)")
                return false
            }
            NotificationCenter.default.post(name: .databaseDidOpen, object: nil)
            return true

        case .delete:
            let db = DatabaseManager.shared
            guard db.isOpen,
                  let entryIdStr = payload.entryId,
                  let entryId    = UUID(uuidString: entryIdStr) else { return false }
            do {
                let entryName = (try? db.fetchAllEntries().first(where: { $0.id == entryId }))?.name
                try db.deleteEntry(id: entryId)
                SecurityEventLogger.shared.log(.entryDeleted,
                    detail: entryName ?? entryIdStr, fromDevice: payload.senderDeviceName)
                PasswordHealthViewModel.runInBackground()
            } catch {
                print("[Sync] delete failed (DB may be closed): \(error)")
                return false
            }
            NotificationCenter.default.post(name: .databaseDidOpen, object: nil)
            return true
        }
    }

    // MARK: - Self-revocation (executed on the device that was remotely revoked)

    /// Called when this device receives a `deviceRevoke` targeting its own inboxId.
    /// Wipes the vault, clears biometrics, deregisters from the server, and locks.
    @MainActor
    private func performSelfRevocation(triggeringMessageId: String, client: RelayAPIClient) async {
        let store = SyncStore.shared
        let db    = DatabaseManager.shared

        SecurityEventLogger.shared.log(.selfRevoked)

        // 1. Delete all vault entries
        if db.isOpen { try? db.deleteAllEntries() }

        // 2. Clear the biometric DEK copy from the Secure Enclave
        BiometricKeyStore.clear()

        // 3. Lock the vault (clears in-memory DEK)
        MasterPasswordManager.shared.lock()

        // 3.5. ACK the device_revoke message that triggered this wipe, using our recvToken
        // WHILE it's still valid — i.e. before deregistering (step 4) or clearAll() (step 5)
        // touch it. The generic per-batch ACK in processMessages runs AFTER applyPayload
        // returns, by which point steps 4–5 below have already deregistered this device and
        // wiped its recvToken — so `store.recvToken` reads nil there and that ACK silently
        // no-ops. Without an explicit ACK here, the triggering message stays un-acked (and,
        // if deregisterDevice below fails/doesn't fully propagate, un-deleted) on the relay —
        // observed in testing 2026-07-18 as this exact device_revoke being redelivered and
        // performSelfRevocation() running a second full teardown cycle. Best-effort: if this
        // fails, the generic end-of-batch ACK attempt is harmless (already a no-op by then).
        if let recvToken = store.recvToken {
            try? await client.ackMessages(recvToken: recvToken, inboxId: store.inboxId,
                                          messageIds: [triggeringMessageId])
        }

        // 4. Best-effort deregister from the relay server (uses our own token)
        if let token = store.sendToken, !store.serverURL.isEmpty {
            try? await makeClient(serverURL: store.serverURL).deregisterDevice(sendToken: token)
        }

        // 5. Stop the sync engine and erase all local sync credentials
        stopEngine()
        store.clearAll()
        isConfigured = false

        // 6. Notify UI to reload (entry list will now be empty)
        NotificationCenter.default.post(name: .databaseDidOpen, object: nil)
    }

    // MARK: - device_hello / device_leave

    private func sendLeave(client: RelayAPIClient, store: SyncStore, sendToken: String) async throws {
        guard let sgk = store.sgk else { return }
        let recipients = store.peers.map { $0.inboxId }
        guard !recipients.isEmpty else { return }

        let payload = SyncPayload(
            eventId: UUID().uuidString,
            senderDeviceId: store.deviceId,
            senderCounter: store.nextCounter(),
            op: .deviceLeave,
            inboxId: store.inboxId,
            deviceName: store.deviceName
        )
        let payloadData = try JSONEncoder().encode(payload)
        let messageId   = UUID().uuidString

        let ciphertext = try SyncCrypto.encrypt(
            payload: payloadData, sgk: sgk, messageId: messageId,
            streamId: store.streamId, senderDeviceId: store.deviceId,
            recipientInboxIds: recipients, senderCounter: payload.senderCounter
        )
        // Use a long TTL so offline peers still learn about the departure
        let msg = OutboundRelayMessage(
            messageId: messageId, streamId: store.streamId,
            senderDeviceId: store.deviceId, recipientInboxIds: recipients,
            ciphertext: ciphertext, encryptedIdentifier: nil,
            ttlSeconds: 30 * 24 * 3600,   // 30 days
            cipherVersion: 1
        )
        _ = try await client.sendMessage(msg, sendToken: sendToken)
    }

    private func sendHello(client: RelayAPIClient, store: SyncStore) async throws {
        guard let sgk = store.sgk, let sendToken = store.sendToken,
              !store.peers.isEmpty else { return }

        let payload = SyncPayload(
            eventId: UUID().uuidString,
            senderDeviceId: store.deviceId,
            senderCounter: store.nextCounter(),
            op: .deviceHello,
            inboxId: store.inboxId,
            deviceName: store.deviceName
        )
        let payloadData = try JSONEncoder().encode(payload)
        let messageId   = UUID().uuidString
        let recipients  = store.peers.map { $0.inboxId }

        let ciphertext = try SyncCrypto.encrypt(
            payload: payloadData, sgk: sgk, messageId: messageId,
            streamId: store.streamId, senderDeviceId: store.deviceId,
            recipientInboxIds: recipients, senderCounter: payload.senderCounter
        )
        let msg = OutboundRelayMessage(
            messageId: messageId, streamId: store.streamId,
            senderDeviceId: store.deviceId, recipientInboxIds: recipients,
            ciphertext: ciphertext, encryptedIdentifier: nil,
            ttlSeconds: nil, cipherVersion: 1
        )
        _ = try await client.sendMessage(msg, sendToken: sendToken)
    }

    /// Announces `peer` to `toInboxId` — used to bootstrap a full peer mesh when this
    /// device learns of a new peer that some of its other peers don't know about yet
    /// (see the full-mesh bootstrap note in applyPayload's `.deviceHello` case). The
    /// payload's `senderDeviceId` field deliberately carries `peer`'s own relay device ID
    /// (not this device's), so the recipient's relayDeviceId bookkeeping for `peer` stays
    /// correct even though this message is relayed rather than sent by `peer` directly —
    /// the AAD binding for decryption uses this device's real ID (passed explicitly below),
    /// so that's unaffected either way.
    private func sendPeerIntroduction(_ peer: SyncPeer, toInboxId: String,
                                       client: RelayAPIClient, store: SyncStore,
                                       sendToken: String) async throws {
        guard let sgk = store.sgk else { return }

        let payload = SyncPayload(
            eventId: UUID().uuidString,
            senderDeviceId: peer.relayDeviceId ?? "",
            senderCounter: store.nextCounter(),
            op: .deviceHello,
            inboxId: peer.inboxId,
            deviceName: peer.deviceName
        )
        let payloadData = try JSONEncoder().encode(payload)
        let messageId    = UUID().uuidString

        let ciphertext = try SyncCrypto.encrypt(
            payload: payloadData, sgk: sgk, messageId: messageId,
            streamId: store.streamId, senderDeviceId: store.deviceId,
            recipientInboxIds: [toInboxId], senderCounter: payload.senderCounter
        )
        let msg = OutboundRelayMessage(
            messageId: messageId, streamId: store.streamId,
            senderDeviceId: store.deviceId, recipientInboxIds: [toInboxId],
            ciphertext: ciphertext, encryptedIdentifier: nil,
            ttlSeconds: 30 * 24 * 3600, cipherVersion: 1   // offline peers must still learn eventually
        )
        _ = try await client.sendMessage(msg, sendToken: sendToken)
    }

    /// Broadcasts a no-op heartbeat so peers' "Last seen" reflects this device even
    /// during stretches where it only receives entries and never sends any.
    private func sendHeartbeat(client: RelayAPIClient, store: SyncStore) async throws {
        guard let sgk = store.sgk, let sendToken = store.sendToken,
              !store.peers.isEmpty else { return }

        let recipients = store.peers.filter { !$0.isSuspended }.map { $0.inboxId }
        guard !recipients.isEmpty else { return }

        var payload = SyncPayload(
            eventId: UUID().uuidString,
            senderDeviceId: store.deviceId,
            senderCounter: store.nextCounter(),
            op: .heartbeat
        )
        payload.senderDeviceName = store.deviceName.isEmpty ? nil : store.deviceName
        payload.inboxId = store.inboxId   // authoritative identity for last-seen matching, see SyncPayload.inboxId
        let payloadData = try JSONEncoder().encode(payload)
        let messageId   = UUID().uuidString

        let ciphertext = try SyncCrypto.encrypt(
            payload: payloadData, sgk: sgk, messageId: messageId,
            streamId: store.streamId, senderDeviceId: store.deviceId,
            recipientInboxIds: recipients, senderCounter: payload.senderCounter
        )
        let msg = OutboundRelayMessage(
            messageId: messageId, streamId: store.streamId,
            senderDeviceId: store.deviceId, recipientInboxIds: recipients,
            ciphertext: ciphertext, encryptedIdentifier: nil,
            ttlSeconds: 3600, cipherVersion: 1   // short TTL — a stale heartbeat is useless
        )
        _ = try await client.sendMessage(msg, sendToken: sendToken)
    }

    // MARK: - Helpers

    private func makeClient(serverURL: String) -> RelayAPIClient {
        RelayAPIClient(baseURL: URL(string: serverURL)!)
    }

    private func encodePairingCode(serverURL: String, streamId: String,
                                    sgk: Data, inboxId: String,
                                    deviceName: String) throws -> String {
        let code = PairingCode(serverURL: serverURL, streamId: streamId,
                               sgk: base64urlEncode(sgk), inboxId: inboxId,
                               deviceName: deviceName)
        return base64urlEncode(try JSONEncoder().encode(code))
    }
}
