import Foundation
import NucleicProtocol

/// The Mac-side sync server (SYNC_PROTOCOL §1: "host is the single authority"). Owns the
/// listener, the paired-device pins, and the active pairing secret; spawns a
/// `ConnectionHandler` per client and fans the bridge's broadcasts out to all of them.
/// Transport-agnostic — hand it a `LANListener` in the app or an in-memory one in tests.
public actor SyncHost {
    private let identity: DeviceIdentity
    private let bridge: any SyncHostBridge
    private let store: any PairedDeviceStore
    /// Optional out-of-band push path (docs/CLOUD_INFRA.md §3); nil until the relay is
    /// configured — everything else works unchanged without it. Deliberately relay-only:
    /// the APNS provider key must never live on a user's machine (any client-held secret
    /// is readable by the machine owner), so pushes are always sent by the relay, which
    /// keeps the key in Worker secrets.
    private let pushRelay: PushRelayClient?
    /// Mints a device's relay membership (mesh P2); nil while the Nucleic Private Relay
    /// method is off. Called after every registration so already-paired devices adopt the
    /// relay and the ~90-day token refreshes on each connect — over any transport.
    private let relayMembership: (@Sendable (_ deviceID: String) async -> WireRelayMembership?)?

    /// How long a superseded connection lingers before it's closed when the same device
    /// connects again (mesh P4 dedup — keep the newest, close the old after a grace period
    /// so an in-flight reply on the old socket can still flush). Tests shrink it.
    private let dedupGrace: Duration

    /// How long to wait after a device's *LAN* connection drops before nudging it (out-of-band, via
    /// APNs) to reconnect over a non-local transport (tailnet/relay). The grace lets a device that
    /// redials itself — a foreground phone, or one that hopped Wi-Fi→cellular and came back over
    /// relay on its own — reconnect first, so it isn't woken redundantly (SYNC_PROTOCOL §3.2). Tests
    /// shrink it.
    private let reconnectNudgeGrace: Duration

    /// How long an authenticated connection may go with *no* inbound frame before the host reaps it
    /// as dead. The host-side counterpart to `SyncClient`'s keepalive: every live client sends at
    /// least a ping every 20s, so a lapse past this means the peer is silently gone (a suspended
    /// phone, a half-open tunnel that never EOFs). Generously above the 20s ping so a live-but-idle
    /// link is never falsely dropped; slightly above the client's own 55s deadline so the client
    /// reconnects first and this is the backstop. Tests shrink it.
    private let connectionLivenessDeadline: Duration

    /// Phones we push Live Activity updates to (UX_IOS §5.3): device id → its activity's APNS
    /// update token. Kept across disconnects on purpose — the whole point is to refresh the
    /// lock-screen glance while the phone is away, so the token outlives the socket. Cleared on
    /// `endLiveActivity` (or replaced when the phone re-registers a fresh activity).
    private var liveActivityTokens: [String: String] = [:]
    /// Phones we can *push-to-start* a Live Activity on (iOS 17.2+): device id → its device-scoped
    /// push-to-start token. Unlike `liveActivityTokens` (a per-activity update token that exists only
    /// once the phone has an Activity), this token exists before any Activity does and survives app
    /// termination — it lets the host bring the glance into being over APNs when work starts while
    /// the app is closed, the fix for "the Live Activity only appears after opening the app". Kept
    /// across disconnects for the same reason as the update tokens.
    private var pushToStartTokens: [String: String] = [:]
    /// Devices we've sent a start push to and are waiting to see adopt the activity (its per-activity
    /// update token then arrives via `registerLiveActivity`). Gates repeat starts: every `start` push
    /// creates a *new* Activity, so without this a burst of broadcasts would stack duplicate glances
    /// on the lock screen. Cleared when the update token arrives, when the activity ends, or when a
    /// fresh push-to-start token is registered.
    private var pushStarted: Set<String> = []
    /// Per-connected-device foreground flag from `ClientMsg.setForeground` (absent = a client that
    /// predates the message). A *foreground* phone creates its own Live Activity over the socket, so
    /// the host defers; a *backgrounded* one can't reliably start one — even while its socket briefly
    /// lingers after it suspends — so the host push-to-starts the glance instead. Cleared on
    /// disconnect so a stale value can never suppress a reconnecting phone's push-to-start.
    private var deviceForeground: [String: Bool] = [:]
    /// The last glance pushed per device, so an unchanged aggregate doesn't burn APNS budget
    /// (nil = nothing pushed / ended).
    private var lastLiveActivity: [String: LiveActivitySnapshot] = [:]
    /// Devices whose glance is currently *held* on the terminal "Done" summary: work finished while
    /// the phone was away, so instead of dismissing the glance we keep the Activity alive showing the
    /// finished sessions until the phone reports it foregrounded — the user opened the app and saw
    /// them (UX_IOS §5.3), rather than the run dropping off the lock screen after a few seconds unseen.
    /// The per-activity update token is deliberately *kept* while held — it's what dismisses the glance
    /// once the app comes forward. Cleared when work resumes, on foreground, or when the activity ends.
    private var heldDoneGlance: Set<String> = []
    /// When the last glance was pushed per device — the clock for the mid-turn churn refresh. A
    /// status change (attention-signature shift) pushes immediately regardless; between those, a
    /// churn-only change (diff totals ticking as the agent edits) only re-pushes once
    /// `churnRefreshInterval` has elapsed, so a busy turn refreshes the numbers slowly instead of
    /// on every transcript delta. This is a passive timestamp check on the normal push path — no
    /// background timer sits in front of a status/approval/input push, so alerts are never delayed.
    private var lastLiveActivityPushAt: [String: Date] = [:]
    /// How slowly the mid-turn diff totals refresh while nothing else about a session changes.
    /// Conservative on purpose — the churn is background colour, not an alert, so a stale count for
    /// up to this long is fine and well worth not hammering APNs. Mirrors the phone's constant.
    private static let churnRefreshInterval: TimeInterval = 45
    /// Each phone's APNs topic (its own bundle id), reported in its `hello`. Per-channel builds
    /// are suffixed (…`.canary`), and APNs `BadTopic`-rejects a push whose topic isn't the target
    /// app's bundle id — so wake + Live Activity pushes must carry *this* device's topic, not one
    /// hardcoded relay-side. Kept across disconnects alongside the Live Activity token.
    private var deviceTopics: [String: String] = [:]
    /// Coalesces Live Activity pushes onto one serial task so the newest aggregate always wins.
    /// A status change fires a broadcast, and firing an unstructured `Task` per broadcast let a
    /// later push race an earlier one: each rebuilds the glance from `bridge.sessionSummaries()`
    /// across an `await`, so an *older* snapshot could resume last and overwrite a newer one —
    /// freezing the lock-screen glance on a stale state (e.g. a session shown still "working" after
    /// it finished, so "marked done" never lands). `pending` records that a refresh is owed; the
    /// single `task` drains it, re-running while more arrive. Mirrors the phone's
    /// `LiveActivityManager` drainer, which was refactored away from this exact race.
    private var liveActivityPushPending = false
    private var liveActivityPushTask: Task<Void, Never>?

    private var listener: (any SyncListener)?
    private var handlers: Set<ConnectionHandler> = []
    /// The newest authenticated connection per device (mesh P4 dedup).
    private var handlersByDevice: [String: ConnectionHandler] = [:]
    private var pairingSecret: Data?
    private var acceptTask: Task<Void, Never>?
    private var broadcastTask: Task<Void, Never>?

    public init(
        identity: DeviceIdentity,
        bridge: any SyncHostBridge,
        store: any PairedDeviceStore = InMemoryPairedDeviceStore(),
        pushRelay: PushRelayClient? = nil,
        relayMembership: (@Sendable (_ deviceID: String) async -> WireRelayMembership?)? = nil,
        dedupGrace: Duration = .seconds(2),
        reconnectNudgeGrace: Duration = .seconds(3),
        connectionLivenessDeadline: Duration = .seconds(60)
    ) {
        self.identity = identity
        self.bridge = bridge
        self.store = store
        self.pushRelay = pushRelay
        self.relayMembership = relayMembership
        self.dedupGrace = dedupGrace
        self.reconnectNudgeGrace = reconnectNudgeGrace
        self.connectionLivenessDeadline = connectionLivenessDeadline
    }

    // MARK: - Lifecycle

    public func start(listener: any SyncListener) throws {
        guard self.listener == nil else { return }
        self.listener = listener
        let connections = try listener.start()

        acceptTask = Task { [weak self] in
            for await connection in connections {
                await self?.accept(connection)
            }
        }
        broadcastTask = Task { [weak self, bridge] in
            for await broadcast in await bridge.broadcasts() {
                await self?.fanout(broadcast)
            }
        }
    }

    public func stop() async {
        acceptTask?.cancel(); acceptTask = nil
        broadcastTask?.cancel(); broadcastTask = nil
        listener?.stop(); listener = nil
        for handler in handlers { await handler.close() }
        handlers.removeAll()
        handlersByDevice.removeAll()
        pairingSecret = nil
    }

    private func accept(_ connection: any FrameChannel) {
        let handler = ConnectionHandler(
            connection: connection, bridge: bridge,
            identity: identity, store: store, host: self,
            livenessDeadline: connectionLivenessDeadline)
        Task { await handler.run() }
    }

    // MARK: - Handler registry (called by ConnectionHandler)

    /// Register an authenticated connection. If the same device already holds a live
    /// connection, the newer one wins: the older handler is closed after `dedupGrace`
    /// (mesh P4 — a reconnecting peer must not end up fanned out twice forever).
    func register(_ handler: ConnectionHandler, deviceID: String) {
        handlers.insert(handler)
        guard !deviceID.isEmpty else { return }
        if let old = handlersByDevice[deviceID], old != handler {
            let grace = dedupGrace
            Task { [weak old] in
                try? await Task.sleep(for: grace)
                await old?.close()
            }
        }
        handlersByDevice[deviceID] = handler
        Task { [bridge] in await bridge.meshPeersChanged() }
        // Issue/refresh the device's relay credential off-path (mesh P2). Best-effort: a
        // failed mint just means the device keeps its previous token (or none).
        if let relayMembership {
            Task { [weak handler] in
                guard let membership = await relayMembership(deviceID) else { return }
                await handler?.deliverRelayMembership(membership)
            }
        }
    }

    func unregister(_ handler: ConnectionHandler, deviceID: String) {
        handlers.remove(handler)
        // Whether this device still has a *current* live handler: a reconnect may have already
        // superseded this one (mesh P4 dedup keeps the newest in the map), in which case the drop
        // isn't a real disconnect and must not trigger a reconnect nudge.
        let stillConnected = !deviceID.isEmpty && handlersByDevice[deviceID] != nil
            && handlersByDevice[deviceID] != handler
        if !deviceID.isEmpty, handlersByDevice[deviceID] == handler {
            handlersByDevice[deviceID] = nil
            // Drop the reported foreground flag so a stale value can't mark a now-disconnected phone
            // as self-creating; the push-to-start gate then falls back to raw connectivity for it.
            deviceForeground[deviceID] = nil
        }
        // A *LAN* link just dropped and nothing newer replaced it — the device may have walked out
        // of Wi-Fi range. Nudge it (out-of-band) onto a non-local transport so it doesn't silently
        // go dark until the user reopens the app. Only LAN drops qualify: a tailnet/relay drop is
        // already non-local, and the device's own dial loop handles it.
        if handler.transport == .lan, !stillConnected {
            nudgeReconnectAfterLANDrop(deviceID: deviceID)
        }
        Task { [bridge] in await bridge.meshPeersChanged() }
    }

    /// A device's LAN connection dropped. After a short grace (so a self-redialing device isn't
    /// woken redundantly), if it still hasn't re-established over *any* transport, send an
    /// out-of-band APNs nudge asking it to reconnect over a non-local path (tailnet/relay). This is
    /// the only way to reach a phone whose LAN link is gone — the Mac can't dial it (SYNC_PROTOCOL
    /// §3.2). No-op when the relay/push path isn't configured.
    private func nudgeReconnectAfterLANDrop(deviceID: String) {
        guard let pushRelay, !deviceID.isEmpty else { return }
        let grace = reconnectNudgeGrace
        Task { [weak self] in
            try? await Task.sleep(for: grace)
            await self?.sendReconnectNudgeIfStillGone(deviceID: deviceID, pushRelay: pushRelay)
        }
    }

    private func sendReconnectNudgeIfStillGone(deviceID: String, pushRelay: PushRelayClient) async {
        // Reconnected over any transport during the grace? Nothing to nudge.
        guard handlersByDevice[deviceID] == nil else { return }
        // Only a push-registered device (a phone) can be woken out-of-band; a Mac peer carries no
        // APNs token and redials itself through its own candidate chain, so it's naturally skipped.
        guard let device = await store.device(deviceID),
              let token = device.pushToken, !token.isEmpty else { return }
        await pushRelay.reconnect(deviceID: deviceID)
    }

    /// Device IDs with a live connection right now (mesh P4 presence — the `online` flag on
    /// `PeerSummary`). Empty string handlers (pre-hello) are dropped.
    public func connectedDeviceIDs() async -> Set<String> {
        var ids: Set<String> = []
        for handler in handlers {
            let id = await handler.connectedDeviceID()
            if !id.isEmpty { ids.insert(id) }
        }
        return ids
    }

    /// A phone's APNS token arrived in its sync Hello — mirror it to the relay so the
    /// out-of-band wake path works even for LAN-only pairings. Best-effort, off-path.
    func didReceivePushToken(deviceID: String, token: String) {
        guard let pushRelay else { return }
        let topic = deviceTopics[deviceID]
        Task { await pushRelay.registerToken(deviceID: deviceID, token: token, topic: topic) }
    }

    /// Record a phone's APNs topic (its bundle id, from `hello`). Used to address every push to
    /// that device; stored independently of the token so a Live-Activity-only phone still gets
    /// the right topic. An empty/absent topic leaves the relay to fall back to its configured one.
    func setPushTopic(deviceID: String, topic: String?) {
        guard let topic, !topic.isEmpty else { return }
        deviceTopics[deviceID] = topic
    }

    // MARK: - Live Activity push (UX_IOS §5.3)

    /// A phone registered (or refreshed) its Live Activity's APNS update token — start pushing the
    /// aggregate glance to it so the lock screen stays fresh while it's backgrounded.
    func registerLiveActivity(deviceID: String, token: String) {
        guard pushRelay != nil, !deviceID.isEmpty, !token.isEmpty else { return }
        liveActivityTokens[deviceID] = token
        pushStarted.remove(deviceID)  // a push-started activity is now tracked via its update token
        heldDoneGlance.remove(deviceID)  // a fresh activity voids any held "Done" glance state
        lastLiveActivity[deviceID] = nil  // force the next push to send the current state
        scheduleLiveActivityPush()
    }

    /// A phone registered (or refreshed) its device-scoped push-to-start token (iOS 17.2+) — remember
    /// it so the next time work starts while the app is closed we can *create* the Live Activity over
    /// APNs instead of waiting for the user to open the app (UX_IOS §5.3). Kept even while the phone
    /// also has a live activity: the app rotates this token independently, and it's what we fall back
    /// to once that activity ends and a later run needs to bring a fresh glance up cold.
    func registerPushToStartToken(deviceID: String, token: String) {
        guard pushRelay != nil, !deviceID.isEmpty, !token.isEmpty else { return }
        // A fresh token voids any in-flight start (the old activity, if any, is unreachable by it).
        if pushToStartTokens[deviceID] != token { pushStarted.remove(deviceID) }
        pushToStartTokens[deviceID] = token
        scheduleLiveActivityPush()
    }

    /// A phone reported whether its app is foreground or backgrounded (`ClientMsg.setForeground`).
    /// Steers the push-to-start gate: a foreground phone self-creates its glance over the socket (the
    /// host defers), a backgrounded one can't, so the host push-to-starts it. Re-evaluate the glance
    /// now so backgrounding while work is already running brings the glance up over APNs.
    func setDeviceForeground(deviceID: String, foreground: Bool) {
        guard !deviceID.isEmpty else { return }
        deviceForeground[deviceID] = foreground
        // The user just opened the app → dismiss any "Done" glance we were holding on the lock screen
        // for them to see (UX_IOS §5.3). They're now on the in-app session list, so the finished run
        // has been seen and the held glance can clear. `endLiveActivity` evicts the kept token and
        // tells the relay to dismiss immediately; the phone also ends its own glance locally on
        // foreground, so this converges rather than conflicts.
        if foreground, heldDoneGlance.contains(deviceID) {
            endLiveActivity(deviceID: deviceID)
        }
        scheduleLiveActivityPush()
    }

    /// The phone's Live Activity ended — stop pushing and tell the relay to dismiss it.
    func endLiveActivity(deviceID: String) {
        pushStarted.remove(deviceID)  // a later run may push-to-start a fresh glance
        heldDoneGlance.remove(deviceID)  // no longer holding a "Done" glance for this device
        guard let token = liveActivityTokens.removeValue(forKey: deviceID) else { return }
        lastLiveActivity[deviceID] = nil
        lastLiveActivityPushAt[deviceID] = nil
        if let pushRelay {
            let topic = deviceTopics[deviceID]
            Task { await pushRelay.endLiveActivity(deviceID: deviceID, token: token, topic: topic) }
        }
    }

    /// Request a Live Activity refresh. Coalesced through one serial task so overlapping requests
    /// (a burst of status broadcasts) can't reorder and let a stale snapshot win — see
    /// `liveActivityPushPending`.
    private func scheduleLiveActivityPush() {
        liveActivityPushPending = true
        guard liveActivityPushTask == nil else { return }  // the running drainer will pick this up
        liveActivityPushTask = Task { [weak self] in await self?.drainLiveActivityPushes() }
    }

    /// Drain coalesced refresh requests one at a time, so pushes never overlap and the freshest
    /// aggregate is always the last one sent. Re-checks `pending` after each push (a broadcast that
    /// arrived mid-push is folded into the next iteration rather than racing this one).
    private func drainLiveActivityPushes() async {
        while liveActivityPushPending {
            liveActivityPushPending = false
            await performLiveActivityPush()
        }
        liveActivityPushTask = nil
    }

    /// Recompute the aggregate glance from the host's live sessions and push it to every
    /// registered phone whose last-pushed state differs. Deduped so an unchanged glance is skipped.
    private func performLiveActivityPush() async {
        guard let pushRelay, !liveActivityTokens.isEmpty || !pushToStartTokens.isEmpty else { return }
        let summaries = await bridge.sessionSummaries()
        let snapshot = LiveActivitySnapshot.build(from: summaries)
        // When work just finished, the "Done" glance is the finished sessions to *hold* on an away
        // phone's lock screen until the user opens the app and sees them (below) — so a completed run
        // reads as finished and stays put, rather than vanishing the instant it ends. Nil ⇒ nothing
        // completed to show ⇒ nothing to hold.
        let doneGlance = snapshot == nil ? LiveActivitySnapshot.done(from: summaries) : nil
        // Who's on the socket right now — steers both the alerting-update decision below and the
        // push-to-start gate further down. Computed once so a single presence read drives both.
        let connected = await connectedDeviceIDs()
        for (deviceID, token) in liveActivityTokens {
            let topic = deviceTopics[deviceID]
            if let snapshot {
                heldDoneGlance.remove(deviceID)  // work is active — no longer holding a "Done" glance
                // Push immediately when the attention signature shifts (a start/finish/approval/
                // input — the alert-worthy changes). Otherwise the only difference is mid-turn churn
                // (diff totals ticking on each transcript edit); refresh that on a slow cadence so it
                // stays roughly current without hammering APNs or the phone's battery. Elapsed-time
                // gate, not a sleeping timer — a signature change is never held behind it.
                let previous = lastLiveActivity[deviceID]
                let signatureChanged = previous?.attentionSignature != snapshot.attentionSignature
                let churnRefreshDue = lastLiveActivityPushAt[deviceID].map {
                    Date().timeIntervalSince($0) >= Self.churnRefreshInterval
                } ?? true
                guard signatureChanged || (previous != snapshot && churnRefreshDue) else { continue }
                lastLiveActivity[deviceID] = snapshot
                lastLiveActivityPushAt[deviceID] = Date()
                // Make the glance *itself* sound/vibrate when a session newly needs the user on an
                // away phone, so `fanout` can skip the separate wake tickle and the phone posts no
                // banner — an active session then shows one surface (the glance), not a Live Activity
                // *and* a second notification. Gated: only when the phone is off the socket (a
                // connected one gets the in-app banner / its own local glance alert, so its pushed
                // update stays silent), only when attention actually *rose* (churn, resolutions,
                // finishes stay silent), and — implicitly — only for a phone with a Live Activity to
                // alert through (this loop is over its tokens). An approval (its Allow/Deny live on the
                // glance) outranks a needs-input for the wording.
                let alert: PushRelayClient.LiveActivityAlert? = connected.contains(deviceID) ? nil
                    : snapshot.approvalCount > (previous?.approvalCount ?? 0) ? .approval
                    : snapshot.needsYouCount > (previous?.needsYouCount ?? 0) ? .input
                    : nil
                await pushRelay.updateLiveActivity(
                    deviceID: deviceID, token: token, topic: topic, snapshot: snapshot,
                    alert: alert)
            } else {
                // Work just finished. Rather than dismiss the glance after a brief linger, HOLD the
                // "Done" summary on the lock screen until the user opens the app and sees the finished
                // sessions (UX_IOS §5.3) — a completed run shouldn't drop off unseen. An *away* phone
                // keeps the Activity alive showing the Done glance; it's dismissed when the phone
                // reports it foregrounded (`setDeviceForeground`). A *foreground* phone is already
                // looking at the in-app list (and ends its own glance locally), so dismiss there.
                let phoneForeground = connected.contains(deviceID) && (deviceForeground[deviceID] ?? true)
                if let doneGlance, !phoneForeground {
                    // Hold once: push the Done glance as an ordinary (non-terminal) update so the
                    // Activity stays alive, keep the token (we need it to dismiss on foreground), and
                    // dedupe on `heldDoneGlance` so a burst of idle broadcasts doesn't re-push it.
                    guard !heldDoneGlance.contains(deviceID) else { continue }
                    heldDoneGlance.insert(deviceID)
                    lastLiveActivity[deviceID] = doneGlance
                    lastLiveActivityPushAt[deviceID] = Date()
                    await pushRelay.updateLiveActivity(
                        deviceID: deviceID, token: token, topic: topic, snapshot: doneGlance)
                } else {
                    // Foreground phone (viewing in-app), or nothing finished to show → dismiss now,
                    // but only once.
                    guard lastLiveActivity[deviceID] != nil || heldDoneGlance.contains(deviceID)
                    else { continue }
                    lastLiveActivity[deviceID] = nil
                    lastLiveActivityPushAt[deviceID] = nil
                    heldDoneGlance.remove(deviceID)
                    // The activity is ending, so its per-activity update token dies with it. Evict it
                    // and clear the push-started marker here — otherwise a phone that's away when work
                    // goes idle never sends `endLiveActivity`, the stale token lingers, and every later
                    // run's push-to-start is wedged by the `liveActivityTokens[deviceID] == nil` guard:
                    // the host would keep pushing updates to a dead activity instead of bringing up a
                    // fresh glance. A still-connected phone re-registers on its next self-create, so
                    // evicting here is safe. (This mirrors the phone-initiated `endLiveActivity`.)
                    liveActivityTokens[deviceID] = nil
                    pushStarted.remove(deviceID)
                    await pushRelay.endLiveActivity(deviceID: deviceID, token: token, topic: topic)
                }
            }
        }

        // Push-to-start (iOS 17.2+) for phones that gave us a push-to-start token. Only relevant for
        // phones that won't create the glance themselves: a *foreground* phone creates and registers
        // the activity over the sync socket (the normal local path), so push-starting it would just
        // risk a duplicate glance. A *backgrounded* or disconnected phone can't self-create, so it
        // needs the push-to-start — even if its socket is briefly still alive after it suspended.
        guard !pushToStartTokens.isEmpty else { return }

        // Create the glance over APNs when work exists and the app is closed — so a session starting
        // while the app is closed surfaces on the lock screen without the user opening the app first
        // (UX_IOS §5.3). Nothing to start when the aggregate is empty (idle) — `build` returned nil.
        if let snapshot {
            let hostName = await bridge.hostInfo.hostName
            for (deviceID, token) in pushToStartTokens {
                // A phone self-creates its glance only while foreground; a backgrounded (or suspended)
                // one can't, even if its socket lingers. Trust the reported foreground flag, falling
                // back to raw connectivity for a client that predates `setForeground` (unknown ⇒ a
                // connected phone is assumed to self-create, the prior behavior). ANDed with
                // `connected` so a stale flag can't mark a disconnected phone as self-creating.
                let selfCreating = connected.contains(deviceID) && (deviceForeground[deviceID] ?? true)
                // Skip devices that already have a live activity to update, ones already started (a
                // second `start` push stacks a duplicate glance), and ones that self-create.
                guard liveActivityTokens[deviceID] == nil, !pushStarted.contains(deviceID),
                      !selfCreating else { continue }
                pushStarted.insert(deviceID)
                // Seed the dedup state so the first *update* push (once the activity's token arrives)
                // is a no-op unless the glance actually moved on since this start.
                lastLiveActivity[deviceID] = snapshot
                lastLiveActivityPushAt[deviceID] = Date()
                let topic = deviceTopics[deviceID]
                await pushRelay.startLiveActivity(
                    deviceID: deviceID, token: token, topic: topic, hostName: hostName, snapshot: snapshot)
            }
        }

        // Nudge phones we've push-started but not yet heard back from (no per-activity update token)
        // to come online in the background and register that token — so live updates, and a clean
        // end, resume without the user opening the app. A push-started activity's update token can
        // only be harvested by the app, which needs a moment of runtime to do it; this silent wake
        // buys that runtime. Throttled relay-side, and skipped once the phone reconnects (it then
        // registers the token over the socket). Runs whether work is active or now idle — an idle
        // nudge lets the woken app end the orphaned glance it can't otherwise reach.
        for deviceID in pushStarted
        where liveActivityTokens[deviceID] == nil && !connected.contains(deviceID) {
            await pushRelay.wakeForLiveActivityAdopt(deviceID: deviceID)
        }
    }

    private func fanout(_ broadcast: HostBroadcast) async {
        for handler in handlers { await handler.deliver(broadcast) }
        // An approval blocks its session on a human — wake paired phones that aren't
        // connected right now (a connected one already got the broadcast frame). The
        // push is a content-free tickle (UX_IOS §5.1); the relay throttles per device.
        if case .approvalRequested(let req) = broadcast, let pushRelay {
            // An `AskUserQuestion` block waits for an *answer*, not an approval — tag the wake so the
            // phone's lock-screen alert reads accurately (the tickle stays content-free otherwise).
            let question = req.toolName == AskUserQuestion.toolName
            let connected = await connectedDeviceIDs()
            for device in await store.all() {
                guard let token = device.pushToken, !token.isEmpty,
                      !connected.contains(device.deviceID),
                      // A phone that already has a Live Activity on screen gets an *alerting* glance
                      // update instead (performLiveActivityPush makes the same broadcast sound/vibrate
                      // through the glance), so skip the separate tickle for it — no Live Activity
                      // *and* a second push for one approval. Phones with only a push-to-start token
                      // (no glance up yet) or no Live Activity at all still get the tickle: there's
                      // nothing on screen to alert through.
                      liveActivityTokens[device.deviceID] == nil else { continue }
                Task { await pushRelay.wake(deviceID: device.deviceID, question: question) }
            }
        }
        // The mirror image: once an approval is resolved and nothing else is pending anywhere, recall
        // the "waiting for your approval" tickle from phones that got it while away — a silent clear
        // push (the counterpart to the wake above). Instant recall matters: a stale "a session needs
        // you" that's already been handled is exactly the nag this is meant to retire. Gated on the
        // aggregate hitting zero so a still-pending approval's nudge isn't dropped (its own wake
        // already fired and won't re-fire). A connected phone clears the tickle off the broadcast
        // frame instead — these are the ones that never saw the resolution.
        if case .approvalResolved = broadcast, let pushRelay, await totalPendingApprovals() == 0 {
            let connected = await connectedDeviceIDs()
            for device in await store.all() {
                guard let token = device.pushToken, !token.isEmpty,
                      !connected.contains(device.deviceID) else { continue }
                Task { await pushRelay.clear(deviceID: device.deviceID) }
            }
        }
        // Note: recalling the "waiting for your approval" tickle after a resolution is handled on the
        // phone, not by a push from here. A connected phone clears the tickle off the `approvalResolved`
        // broadcast (and reconciles the shared tickle when nothing's left); an away phone clears it on
        // its next foreground. We deliberately do *not* send a silent background "clear" push: that
        // relaunches a terminated app, and a background relaunch that reconnects into an empty session
        // list can tear down a push-started Live Activity before the app re-adopts it.
        // Any change to a session's status, diff, or approvals shifts the aggregate glance —
        // refresh the Live Activity for phones registered for push. Off the fanout path (a
        // network round-trip must not stall broadcast delivery); the drainer coalesces and dedupes.
        switch broadcast {
        case .sessionUpdated, .sessionList, .approvalRequested, .approvalResolved:
            if !liveActivityTokens.isEmpty || !pushToStartTokens.isEmpty {
                scheduleLiveActivityPush()
            }
        case .events, .dashboard, .toolSummaries, .macPairRequested, .macPairResolved:
            break
        }
    }

    /// Pending approvals across *every* session, from the same summaries the glance is built
    /// from. The recall push above is gated on this hitting zero: one session's resolution must
    /// not retire the "needs you" tickle while another session still waits.
    private func totalPendingApprovals() async -> Int {
        await bridge.sessionSummaries().reduce(0) { $0 + $1.pendingApprovalCount }
    }

    // MARK: - Pairing (SYNC §4.2)

    /// Begin a pairing window: mint a one-time secret and return the QR payload to display.
    /// The secret stays active until a device completes pairing (`didPair`) or `cancelPairing`.
    /// The connection hints describe whichever transport the host's listener is bound to.
    public func beginPairing(
        lanHost: String? = nil, lanPort: UInt16? = nil,
        transport: SyncTransportHint? = nil,
        tailnetHost: String? = nil, tailnetPort: UInt16? = nil,
        relay: WireRelayMembership? = nil
    ) async -> PairingPayload {
        let secret = PairingPayload.freshSecret()
        pairingSecret = secret
        return PairingPayload(
            hostName: await bridge.hostInfo.hostName,
            hostStaticKey: identity.staticPublicKey,
            pairingSecret: secret, lanHost: lanHost, lanPort: lanPort,
            transport: transport?.rawValue, tailnetHost: tailnetHost, tailnetPort: tailnetPort,
            relayRoomID: relay?.roomID, relayMembershipToken: relay?.token, relayURL: relay?.url)
    }

    public func cancelPairing() { pairingSecret = nil }
    public func pairedDevices() async -> [PairedDevice] { await store.all() }
    public func revoke(deviceID: String) async { await store.remove(deviceID) }

    /// Push a fresh mesh roster to every connected client that speaks roster gossip (mesh "join").
    /// Called when membership changed (a device joined, revoked, or refreshed addresses) so
    /// still-connected members converge without waiting for their next reconnect.
    public func broadcastMeshRoster(_ push: MeshRosterPush) async {
        for handler in handlers { await handler.deliverMeshRoster(push) }
    }

    /// Record a revocation tombstone and drop the pin (mesh "join" group-wide revoke) — the pinned
    /// key is gone so the device's next handshake fails `.unauthorized`, and the tombstone rides
    /// gossip so other members drop it too and can't re-introduce it.
    public func tombstone(deviceID: String, at revokedAt: Date = Date()) async {
        await store.tombstone(MeshTombstone(deviceID: deviceID, revokedAt: revokedAt))
    }

    /// This host's current group view (live members + tombstones), for gossip.
    public func meshRoster() async -> MeshRosterPush {
        MeshRosterPush(
            members: await store.all().map(MeshMember.init(pinned:)),
            tombstones: await store.tombstones())
    }

    func activePairingSecret() -> Data? { pairingSecret }
    func didPair() { pairingSecret = nil }
}
