// 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://mypwdtool.com/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 leaveRequiresConnection

    var errorDescription: String? {
        switch self {
        case .invalidServerURL:   return String(localized: "Invalid server URL")
        case .invalidPairingCode: return String(localized: "Invalid pairing code")
        case .leaveRequiresConnection:
            return String(localized: "Couldn't reach the relay to notify the group. Sync is still active — try again when the network is available.")
        }
    }
}

// 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 controlOutboxWatcherTask: Task<Void, Never>?
    private var cancellables = Set<AnyCancellable>()

    // Long-poll hold durations, passed as `waitMs` to the relay — the connection is held open
    // server-side for up to this long, returning the instant a new message arrives, or empty
    // once the hold expires. 55s leaves a 5s margin under the relay's own MAX_WAIT_MS=60000 so a
    // slow network doesn't race the server's own timeout. Same value on iOS and macOS — unlike
    // the old short-poll intervals this replaced, there's no battery-vs-latency tradeoff to tune
    // per platform: a held idle connection costs about the same as a closed one between Go's
    // goroutine-per-request relay and a backgrounded client (see 2026-10-01 decision in
    // project_roadmap.md — short-polling here was carried over from thread-per-connection server
    // assumptions, like Tomcat, that don't apply to the Go relay).
    private let longPollWaitMs: Int = 55_000
    // Short-hold long poll used during known-active bursts (right after joining/pairing, or
    // while a full 100-message batch suggests more are queued) — still long-polling, just with a
    // short hold, so a burst feels responsive without the artificial local sleep the old
    // short-poll design needed.
    private let fastPollWaitMs: Int = 2_000

    /// Deadline-based fast poll: active while Date() < fastPollDeadline.
    /// Set by enableFastPoll(for:) — e.g. 30 seconds 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 pollFailureCount = 0

    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)

        // Independent of startEngine()/stopEngine()'s lifecycle — SyncControlOutbox is otherwise
        // only ever drained from sendOutboxItems(), which only runs while the poll loop/engine
        // is active. disableSync() stops the engine and clears SyncStore right after queuing a
        // device_leave retry, so on a device that just left a group, the engine never runs again
        // to drain it — the queued leave message would sit forever, un-retried, and the rest of
        // the group would never learn the device left even once back online. Reproduced live
        // 2026-09-18: offline self-revocation (disable sync) left the other devices thinking the
        // leaving device was still in the group, even after it reconnected. This watcher runs
        // for the app's whole lifetime (SyncManager is a singleton), so a pending control message
        // still gets retried on a schedule regardless of whether sync is currently configured at
        // all — matches the design intent that these messages must eventually be delivered.
        startControlOutboxWatcher()
    }

    private func startControlOutboxWatcher() {
        controlOutboxWatcherTask = Task { [weak self] in
            while !Task.isCancelled {
                if !SyncControlOutbox.shared.isEmpty {
                    await self?.drainControlOutbox()
                }
                try? await Task.sleep(for: .seconds(60))
            }
        }
    }

    // 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()
    }

    /// Renames this device: updates the relay's own `device_name` record (§4.4, diagnostics
    /// only) via PATCH /devices, and separately pushes the new name to paired peers on the
    /// next heartbeat via the encrypted `sender_device_name` payload field — the two are
    /// unrelated on the wire (one is a plaintext relay-visible field, the other is inside the
    /// E2EE payload peers actually display), so both need updating for the rename to be
    /// visible everywhere. Resetting `lastHeartbeatSentAt` and kicking the engine makes the
    /// peer-facing heartbeat go out now instead of up to 30 minutes away.
    func renameDevice(to newName: String) {
        let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        let store = SyncStore.shared
        store.deviceName = trimmed
        lastHeartbeatSentAt = nil
        if let sendToken = store.sendToken, !store.serverURL.isEmpty {
            Task {
                do {
                    try await makeClient(serverURL: store.serverURL).renameDevice(sendToken: sendToken, name: trimmed)
                } catch {
                    // Best-effort: the relay's copy is diagnostics-only, so a failed PATCH here
                    // (offline, transient error) shouldn't block the peer-facing rename below.
                    print("[Sync] relay device rename failed (non-fatal): \(error)")
                }
            }
        }
        forceSyncNow()
    }

    /// 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 a short-hold (2s) long poll for `duration` (default 30s), instead of the
    /// normal 55s hold, so a known-active burst (just joined/paired, or a message mid-flight)
    /// feels responsive. Cancels the in-progress long poll so the short-hold window starts now
    /// rather than after the current (possibly 55s-long) hold finishes.
    private func enableFastPoll(for duration: TimeInterval = 30) {
        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()
        // Same reasoning as currentPairingCode(): the pairing code returned below is about to be
        // displayed to invite the group's first joiner, so this device should fast-poll for it
        // immediately rather than waiting up to a full normal-interval poll away.
        enableFastPoll()
        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)

    /// Deliberately no entitlement check in here (mirrors `createGroup()`, which never had one
    /// either) — as of 2026-09-14 every platform has a real purchase flow (Play Billing on
    /// Android, Lemon Squeezy on Windows/Linux), so the old "one free invite per vault without
    /// paying" compromise (`sync_one_shot_invite_used`, designed 2026-07-30 specifically for
    /// Kotlin having no purchase flow at all — see git history for the removed
    /// `canShowPairingCode`/`SyncError.oneShotInviteAlreadyUsed`) no longer has a reason to
    /// exist. Policy now lives entirely at the UI layer: the caller checks
    /// `StoreManager.shared.isSyncPurchased` before ever calling this, and shows the paywall
    /// instead of calling it at all when not entitled (option (c), decided 2026-09-14: "Add a
    /// device" stays visible and tappable regardless of entitlement, leading straight to
    /// `SyncPaywallView` rather than a disabled control or a "1 left"/"0 left" label).
    func currentPairingCode() throws -> String {
        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)
        // The inviting device otherwise only discovers the new joiner once its current long-poll
        // hold (up to 55s) expires — the joiner itself gets a fast 2s-hold poll immediately via
        // joinGroup()'s own enableFastPoll(), but until now the inviter side had no equivalent, so
        // a fresh join looked "not seen" for up to that long. Starts the moment the code is
        // shown, not whenever the display sheet happens to be dismissed. startEngine() first is
        // required, not just enableFastPoll() alone: the poll loop only re-checks its fast-poll
        // deadline once its current long-poll hold finishes (or is cancelled), so calling
        // enableFastPoll() mid-hold wouldn't take effect until that hold's own up-to-55s delay
        // elapsed anyway — same restart-to-take-effect-now pattern joinGroup() already relies on.
        // Added 2026-09-10 (ported from the Kotlin client, which had the same gap).
        startEngine()
        enableFastPoll()
        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(for: 180)     // 2s-hold polling for 3 min while bulk sync messages are in flight
        SecurityEventLogger.shared.log(.syncGroupJoined)

        // Announce ourselves so the initiator learns our inbox. Non-throwing: a failed send here
        // is queued for retry (see attemptHello) instead of failing the join, which had already
        // fully succeeded locally by this point.
        await attemptHello(client: client, store: store, sendToken: registration.sendToken)

        // 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
        store.peers = store.peers.filter { $0.inboxId != peer.inboxId }
        store.markRevoked(inboxId: peer.inboxId)
        SecurityEventLogger.shared.log(.deviceRevoked, detail: peer.deviceName)

        // Both attempts below are non-fatal to the local suppression above (already applied,
        // unconditionally) — they're what lets OTHER devices and the target itself learn about
        // the revoke. If either fails right now (most commonly: offline at this exact moment),
        // it's queued on SyncControlOutbox and retried automatically the same way an entry
        // upsert would be (poll loop, kickDrain, engine start) — no more "retry by revoking
        // again once reachable." Found live 2026-09-18.
        if !(await attemptDeviceRevokeSend(targetInboxId: peer.inboxId, targetDeviceName: peer.deviceName)) {
            SyncControlOutbox.shared.enqueue(.init(label: "device_revoke(\(peer.deviceName))") { [weak self] in
                await self?.attemptDeviceRevokeSend(targetInboxId: peer.inboxId, targetDeviceName: peer.deviceName) ?? false
            })
        }
        if !(await attemptKeyRotationSend()) {
            SyncControlOutbox.shared.enqueue(.init(label: "key_rotate(after revoking \(peer.deviceName))") { [weak self] in
                await self?.attemptKeyRotationSend() ?? false
            })
        }
    }

    /// Re-derives recipients/SGK fresh from current `SyncStore` state on every call (first
    /// attempt and every retry) — see `SyncControlOutbox`'s doc comment for why that matters here.
    /// Always includes `targetInboxId` even though it's no longer in `store.peers`, so the
    /// revoked device itself still receives the instruction to self-wipe once it reconnects.
    /// Returns `true` for "nothing to send" (not configured) — that's not a failure worth
    /// retrying, unlike an actual send error.
    private func attemptDeviceRevokeSend(targetInboxId: String, targetDeviceName: String?) async -> Bool {
        let store = SyncStore.shared
        guard let sendToken = store.sendToken, let sgk = store.sgk else { return true }
        var recipients = store.peers.map { $0.inboxId }
        if !recipients.contains(targetInboxId) { recipients.append(targetInboxId) }
        guard !recipients.isEmpty else { return true }

        let client = makeClient(serverURL: store.serverURL)
        do {
            let payload = SyncPayload(
                eventId: UUID().uuidString,
                senderDeviceId: store.deviceId,
                senderCounter: store.nextCounter(),
                op: .deviceRevoke,
                inboxId: targetInboxId,       // target's inbox — receivers remove this device
                deviceName: targetDeviceName
            )
            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: 30 * 24 * 3600,  // 30-day TTL: offline device gets it when it reconnects
                cipherVersion: 1
            )
            _ = try await client.sendMessage(msg, sendToken: sendToken)
            return true
        } catch {
            print("[Sync] device_revoke send attempt failed: \(error)")
            return false
        }
    }

    /// Re-derives its old SGK/recipients fresh from current `SyncStore` state on every call.
    /// Returns `true` for "nothing to rotate for" (no peers left, or not configured) — not a
    /// failure worth retrying.
    private func attemptKeyRotationSend() async -> Bool {
        let store = SyncStore.shared
        guard let sendToken = store.sendToken, let oldSGK = store.sgk else { return true }
        let recipients = store.peers.map { $0.inboxId }
        guard !recipients.isEmpty else { return true }
        let client = makeClient(serverURL: store.serverURL)
        do {
            try await sendKeyRotation(oldSGK: oldSGK, recipients: recipients,
                                      client: client, store: store, sendToken: sendToken)
            return true
        } catch {
            print("[Sync] key rotation send attempt failed: \(error)")
            return false
        }
    }

    // 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

    /// Unlike `revokeDevice(peer:)` (which must apply immediately regardless of network — you may
    /// not trust or control the target device, so the local suppression can't wait on a round
    /// trip), leaving the group yourself has no such urgency, and an optimistic "leave locally no
    /// matter what" design has a real downside: if this device can never reach the relay again,
    /// the group's SGK never gets rotated away from a key this device still holds. So self-leave
    /// requires actually reaching the relay first: if the `device_leave` broadcast fails right
    /// now (most commonly, offline), this device stays fully in the group — sync keeps running,
    /// local state is untouched — and this throws `.leaveRequiresConnection` instead of
    /// pretending to have left. A retry is queued for the rest of *this* app session only (see
    /// `SyncControlOutbox`, in-memory only): if connectivity returns before the app quits, the
    /// leave completes automatically in the background; if not, the next launch finds the device
    /// still in the group, exactly as if "Disable sync" had never been tapped — the user just
    /// taps it again once actually connected. Changed 2026-09-19 from the previous
    /// always-succeeds-locally design.
    func disableSync() async throws {
        isBusy = true
        defer { isBusy = false }

        let store = SyncStore.shared
        guard store.isConfigured else {
            stopEngine()
            store.clearAll()
            isConfigured = false
            return
        }

        guard let sendToken = store.sendToken, let sgk = store.sgk else {
            // Not actually configured enough to notify anyone — nothing worth waiting on.
            stopEngine()
            store.clearAll()
            isConfigured = false
            return
        }

        guard !store.peers.isEmpty else {
            // No one to notify — safe to leave immediately, matches the guard above.
            stopEngine()
            if !store.serverURL.isEmpty {
                try? await makeClient(serverURL: store.serverURL).deregisterDevice(sendToken: sendToken)
            }
            store.clearAll()
            isConfigured = false
            return
        }

        let serverURL = store.serverURL
        let msg = try buildLeaveMessage(store: store, sgk: sgk)

        // No queued retry here (unlike device_revoke/key_rotate) — a background retry firing
        // once connectivity returns can't be verified end-to-end (the relay accepting the send
        // doesn't guarantee the other devices actually applied it), and a false "succeeded" would
        // silently desync this device from the group's own view of membership with no way for
        // the user to notice. Reproduced live 2026-09-19: two mobile devices went offline,
        // disabled sync, got queued for retry, and once back online considered themselves out of
        // the group — but macOS still saw them as active members, and the relay still had their
        // device registrations. Requiring a fresh, user-visible manual retry keeps this
        // observable and simple instead of silently wrong.
        guard await attemptResend(msg, sendToken: sendToken, serverURL: serverURL) else {
            print("[Sync] device_leave send failed — staying in the group, no automatic retry")
            throw SyncError.leaveRequiresConnection
        }

        finishLeavingGroup(serverURL: serverURL, sendToken: sendToken)
    }

    /// The actual local-leave step, shared between disableSync()'s immediate-success path and
    /// its queued retry's eventual success path.
    private func finishLeavingGroup(serverURL: String, sendToken: String) {
        stopEngine()
        Task {
            try? await makeClient(serverURL: serverURL).deregisterDevice(sendToken: sendToken)
        }
        SyncStore.shared.clearAll()
        isConfigured = false
    }

    /// Resends an already-built message as-is — used for control messages whose content doesn't
    /// depend on group state that could keep changing after the first attempt (device_leave,
    /// device_hello, peer introductions), unlike device_revoke/key_rotate, which re-derive fresh
    /// on every attempt instead (see attemptDeviceRevokeSend/attemptKeyRotationSend).
    private func attemptResend(_ msg: OutboundRelayMessage, sendToken: String, serverURL: String) async -> Bool {
        do {
            _ = try await makeClient(serverURL: serverURL).sendMessage(msg, sendToken: sendToken)
            return true
        } catch {
            print("[Sync] resend attempt failed: \(error)")
            return false
        }
    }

    private func buildLeaveMessage(store: SyncStore, sgk: Data) throws -> OutboundRelayMessage {
        let recipients = store.peers.map { $0.inboxId }
        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
        return OutboundRelayMessage(
            messageId: messageId, streamId: store.streamId,
            senderDeviceId: store.deviceId, recipientInboxIds: recipients,
            ciphertext: ciphertext, encryptedIdentifier: nil,
            ttlSeconds: 30 * 24 * 3600,   // 30 days
            cipherVersion: 1
        )
    }

    // MARK: - Poll retry backoff

    /// Honours the relay's own Retry-After on 429; otherwise backs off exponentially from 30s to a
    /// 5-min cap, with ±20% jitter so devices that all lost the relay together don't retry in
    /// lockstep. Reset by the next successful poll.
    private func pollRetryDelay(after error: Error) -> Duration {
        if case RelayError.rateLimited(let seconds) = error {
            return .seconds(seconds)
        }
        pollFailureCount += 1
        let base = min(30 * (1 << min(pollFailureCount - 1, 4)), 300)
        return .seconds(Double(base) * Double.random(in: 0.8...1.2))
    }

    // MARK: - Poll loop (long-poll: the relay holds the connection open and replies the instant
    // a message arrives, or empty once `waitMs` elapses — no local sleep needed between calls,
    // the hold itself paces the loop)
    //
    // Switched from short-polling (wait_ms=0 + a local sleep) 2026-10-01, once the relay (a Go
    // service, not a thread-per-connection server) added real long-poll support
    // (MAX_WAIT_MS=60000) — holding an idle connection open costs it about the same as a closed
    // one, so there was no actual reason left to avoid it.

    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, long-poll \(longPollWaitMs)ms)")
        #else
        print("[Sync] poll loop started (macOS, long-poll \(longPollWaitMs)ms)")
        #endif

        while !Task.isCancelled {
            do {
                let cursor = store.cursor.isEmpty ? nil : store.cursor
                let waitMs = isInFastPollMode ? fastPollWaitMs : longPollWaitMs
                let response = try await client.pollMessages(
                    recvToken: recvToken,
                    inboxId: store.inboxId,
                    after: cursor,
                    limit: 100,
                    waitMs: waitMs
                )
                pollFailureCount = 0

                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)
                }

                // Safety-net retry: without this, an outbox item whose very first drain attempt
                // failed its guards in sendOutboxItems() (e.g. no peers yet right after a fresh
                // join) sat stuck forever — nothing else periodically retries it, since draining
                // otherwise only ever happens in direct response to enqueueUpsert/enqueueDelete.
                // Reproduced live (2026-09-05): importing a backup immediately after joining a
                // sync group, the imported entries never reached the other device.
                if !SyncOutbox.shared.isEmpty {
                    await sendOutboxItems()
                }

                // No local sleep here: the long poll we just made already held the connection
                // open for up to `waitMs`, which is what used to pace the short-poll loop — the
                // next iteration's hold does that job now.

            } 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 }
                let delay = pollRetryDelay(after: error)
                print("[Sync] poll error: \(error) — retrying in \(delay)")
                try? await Task.sleep(for: delay)
            }
        }
    }

    // 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. `Task.sleep` returns
            // almost immediately (throwing CancellationError) once this task is cancelled
            // rather than actually sleeping out the delay — `try?` alone would swallow that
            // and fall through to sendOutboxItems() anyway. For a *cancelled* predecessor
            // task, that race let it drain the outbox (removing the items) just before its
            // own `Task.isCancelled` check inside sendItems() correctly aborted before
            // sending — silently discarding a whole batch. Reproduced live (2026-09-05):
            // every rapid multi-enqueue burst (a multi-entry backup import, or several
            // deletes in a row) lost its entries this way, while single, well-spaced saves
            // always worked (only one Task was ever created, never cancelled). Bailing out
            // here on cancellation, instead of falling through, leaves the items in the
            // outbox for the newest (non-cancelled) kickDrain() task to actually send.
            do { try await Task.sleep(for: .milliseconds(100)) } catch { return }
            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 {
        // Independent of the entry-outbox guards below — a control message (e.g. a device_leave
        // snapshot) may need to send even when this device's own current peers/sgk look empty.
        await drainControlOutbox()

        let store = SyncStore.shared
        // Recipients checked *before* draining — this used to drain first and check recipients
        // after, so "no non-suspended peers" (all currently suspended) discarded the drained
        // items instead of leaving them queued. Reordered so nothing is ever removed from the
        // outbox unless a send is actually about to be attempted.
        let recipients = store.peers.filter { !$0.isSuspended }.map { $0.inboxId }
        guard let sendToken = store.sendToken,
              let sgk = store.sgk,
              !recipients.isEmpty else { return }

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

        let client = makeClient(serverURL: store.serverURL)
        await sendItems(items, toInboxIds: recipients,
                        client: client, store: store,
                        sendToken: sendToken, sgk: sgk)
    }

    /// Drains `SyncControlOutbox` — see its own doc comment for why each item re-derives its
    /// payload instead of replaying a snapshot. Rate-limited the same way `sendItems` is.
    private func drainControlOutbox() async {
        let items = SyncControlOutbox.shared.drain()
        guard !items.isEmpty else { return }
        for item in items {
            guard !Task.isCancelled else {
                SyncControlOutbox.shared.enqueue(item)
                break
            }
            let delivered = await item.send()
            if !delivered {
                print("[Sync] control message retry failed, will retry again later: \(item.label)")
                SyncControlOutbox.shared.enqueue(item)
            }
            try? await Task.sleep(for: sendDelay)
        }
    }

    // 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
        var matchedByInboxId = false
        if let inboxId = senderInboxId {
            peerIdx = store.peers.firstIndex(where: { $0.inboxId == inboxId })
            matchedByInboxId = peerIdx != nil
        }
        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
            }
            // A device can rename itself locally (see SyncSettingsView) and has no other way
            // to tell its peers — it just rides along on whatever it next sends, heartbeat
            // included, in the already-optional `senderDeviceName` field. Older peer builds
            // that never populate this field send `nil` here, which correctly leaves the
            // stored name untouched rather than clobbering it with an empty value. Only apply
            // it when the peer was matched via `inboxId` — the one unique, relay-assigned
            // identifier that can't collide. Live-tested 2026-09-02: pairing an old Kotlin
            // build (relayDeviceId not yet reliably populated) caused the `relayDeviceId`
            // fallback match here to hit the WRONG existing peer, and this then overwrote that
            // peer's name with the new device's name — exactly the false-match risk the
            // existing comment above already called out for name overwriting, just not
            // previously guarded against.
            if matchedByInboxId, let senderName = senderDeviceName, !senderName.isEmpty,
               store.peers[idx].deviceName != senderName {
                store.peers[idx].deviceName = senderName
            }
            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") })")
        }
    }

    private static let isoWithFractional: ISO8601DateFormatter = {
        let f = ISO8601DateFormatter()
        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return f
    }()
    private static let isoPlain = ISO8601DateFormatter()

    /// Parses an ISO-8601 timestamp received from a peer. Peer builds emit these with or
    /// without fractional seconds — Kotlin's `Instant.toString()` (windows-client) almost
    /// always includes them (e.g. "…30.123456789Z"), while Swift's own `ISO8601DateFormatter()`
    /// with default options never does when THIS device is the sender. That formatter's default
    /// options only parse the no-fractional form, so a Kotlin peer's timestamp silently failed
    /// to parse here and fell back to `nil`/`Date()` depending on call site. For `deletedAt`
    /// specifically that meant the field came back `nil` (not deleted) even though the sender
    /// genuinely set it — a Kotlin "move to Bin" upsert applied every other field but silently
    /// never actually deleted anything on Swift (found live 2026-09-02). Trying the
    /// fractional-seconds formatter first, then falling back to the plain one, parses both.
    private func parseSyncDate(_ string: String?) -> Date? {
        guard let string else { return nil }
        return Self.isoWithFractional.date(from: string) ?? Self.isoPlain.date(from: string)
    }

    // 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 {
                        await self.attemptPeerIntroduction(newPeer, toInboxId: other.inboxId,
                                                            client: client, store: store, sendToken: sendToken)
                        await self.attemptPeerIntroduction(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 updatedAt = parseSyncDate(payload.updatedAt) ?? Date()
            let createdAt = parseSyncDate(payload.createdAt) ?? 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                 = parseSyncDate(payload.deletedAt)
                    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: parseSyncDate(payload.deletedAt)
                    )
                    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 = parseSyncDate(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
            }
            // Duplicate-pair detection for synced entries now happens entirely in
            // EntryListViewModel's debounced full rescan (triggered by the databaseDidOpen post
            // just below), which recomputes DuplicateDetector.detect(in:) from scratch rather
            // than reconciling pairs per entry — see its own doc comment for why an inline
            // per-entry computation here was retired (it used the old detectPairs API, which is
            // not equivalent to detect(in:)'s two-pass exclusion rule). Found live 2026-09-18.
            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

    /// Builds this device's own announcement — doesn't send it; see `attemptHello`.
    private func buildHelloMessage(store: SyncStore, sgk: Data) throws -> OutboundRelayMessage? {
        guard !store.peers.isEmpty else { return nil }
        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
        )
        return OutboundRelayMessage(
            messageId: messageId, streamId: store.streamId,
            senderDeviceId: store.deviceId, recipientInboxIds: recipients,
            ciphertext: ciphertext, encryptedIdentifier: nil,
            ttlSeconds: nil, cipherVersion: 1
        )
    }

    /// Builds and attempts to send this device's own announcement; on failure, queues a frozen
    /// resend (see `attemptResend`) instead of throwing — a failed hello here used to fail
    /// `joinGroup` itself even though the join had already fully succeeded locally.
    private func attemptHello(client: RelayAPIClient, store: SyncStore, sendToken: String) async {
        guard let sgk = store.sgk else { return }
        let serverURL = store.serverURL
        do {
            guard let msg = try buildHelloMessage(store: store, sgk: sgk) else { return }
            do {
                _ = try await client.sendMessage(msg, sendToken: sendToken)
            } catch {
                print("[Sync] device_hello send failed: \(error) — queued for retry")
                SyncControlOutbox.shared.enqueue(.init(label: "device_hello") { [weak self] in
                    await self?.attemptResend(msg, sendToken: sendToken, serverURL: serverURL) ?? false
                })
            }
        } catch {
            print("[Sync] device_hello build failed (not retryable): \(error)")
        }
    }

    /// Builds an announcement of `peer` to `toInboxId` — doesn't send it; see
    /// `attemptPeerIntroduction`. 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 buildPeerIntroductionMessage(_ peer: SyncPeer, toInboxId: String,
                                               store: SyncStore, sgk: Data) throws -> OutboundRelayMessage {
        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
        )
        return 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
        )
    }

    /// Builds and attempts to send a peer introduction; on failure, queues a frozen resend.
    private func attemptPeerIntroduction(_ peer: SyncPeer, toInboxId: String,
                                          client: RelayAPIClient, store: SyncStore,
                                          sendToken: String) async {
        guard let sgk = store.sgk else { return }
        let serverURL = store.serverURL
        do {
            let msg = try buildPeerIntroductionMessage(peer, toInboxId: toInboxId, store: store, sgk: sgk)
            do {
                _ = try await client.sendMessage(msg, sendToken: sendToken)
            } catch {
                print("[Sync] peer_introduction send failed: \(error) — queued for retry")
                SyncControlOutbox.shared.enqueue(.init(label: "peer_introduction(\(peer.deviceName))") { [weak self] in
                    await self?.attemptResend(msg, sendToken: sendToken, serverURL: serverURL) ?? false
                })
            }
        } catch {
            print("[Sync] peer_introduction build failed (not retryable): \(error)")
        }
    }

    /// 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))
    }
}
