// LoopFollow
// LiveActivityManager.swift

// swiftformat:disable indent
#if !targetEnvironment(macCatalyst)

@preconcurrency import ActivityKit
import Foundation
import os
import UIKit
import UserNotifications

// Live Activity manager for LoopFollow.
//
// iOS 17.2+:        every LA creation (initial start, renewal, forced
//                   restart) goes through APNs push-to-start. Updates
//                   ride the same APNs transport. One transport, one
//                   credential failure mode that surfaces in settings.
//
// iOS 16.6 – 17.1:  legacy Activity.request() for everything;
//                   renewal-failed notification when backgrounded.
//                   The entry-point `if #available(iOS 17.2, *)` checks
//                   isolate every iOS 17.2 code path, so the legacy
//                   helpers can be deleted in one commit when the
//                   deployment target reaches 17.2.

final class LiveActivityManager {
    static let shared = LiveActivityManager()
    private init() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil,
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleDidBecomeActive),
            name: UIApplication.didBecomeActiveNotification,
            object: nil,
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleWillResignActive),
            name: UIApplication.willResignActiveNotification,
            object: nil,
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleBackgroundAudioFailed),
            name: .backgroundAudioFailed,
            object: nil,
        )
        startPushToStartTokenObservation()
        startActivityUpdatesObservation()
    }

    // MARK: - Push-to-start observation (iOS 17.2+)

    /// Observes the type-level push-to-start token (iOS 17.2+) and persists it.
    /// The token survives app relaunches but is reissued by iOS periodically or when
    /// the user toggles LA permissions — each new delivery overwrites the stored value.
    private func startPushToStartTokenObservation() {
        if #available(iOS 17.2, *) {
            pushToStartObservationTask?.cancel()
            LogManager.shared.log(
                category: .general,
                message: "[LA] pushToStartTokenUpdates observation starting (iOS 17.2+)"
            )
            pushToStartObservationTask = Task {
                var deliveries = 0
                for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
                    deliveries += 1
                    let token = tokenData.map { String(format: "%02x", $0) }.joined()
                    let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
                        ? "nil"
                        : String(Storage.shared.laPushToStartToken.value.suffix(8))
                    let tail = String(token.suffix(8))
                    let changed = tail != previousTail
                    Storage.shared.laPushToStartToken.value = token
                    LogManager.shared.log(
                        category: .general,
                        message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
                    )
                }
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
                )
            }
        } else {
            LogManager.shared.log(
                category: .general,
                message: "[LA] pushToStartTokenUpdates unavailable (iOS <17.2) — push-to-start will never fire"
            )
        }
    }

    /// Observes new Activity creations. When an activity is started by
    /// push-to-start (iOS 17.2+), the app discovers it through this stream and
    /// adopts it via the same bind/update path as an app-initiated start.
    private func startActivityUpdatesObservation() {
        activityUpdatesObservationTask?.cancel()
        LogManager.shared.log(
            category: .general,
            message: "[LA] activityUpdates observation starting"
        )
        activityUpdatesObservationTask = Task { [weak self] in
            var deliveries = 0
            for await activity in Activity<GlucoseLiveActivityAttributes>.activityUpdates {
                deliveries += 1
                let incomingID = activity.id
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] activityUpdates delivery #\(deliveries) id=\(incomingID) — dispatching to MainActor"
                )
                await MainActor.run {
                    self?.adoptPushToStartActivity(activity)
                }
            }
            LogManager.shared.log(
                category: .general,
                message: "[LA] activityUpdates stream ended after \(deliveries) deliveries — push-to-start adoption will no longer work until app relaunch"
            )
        }
    }

    @MainActor
    private func adoptPushToStartActivity(_ activity: Activity<GlucoseLiveActivityAttributes>) {
        // Skip if it's the activity we already track (app-initiated path binds it directly).
        if current?.id == activity.id {
            LogManager.shared.log(
                category: .general,
                message: "[LA] activityUpdates: ignoring own activity id=\(activity.id) (already current)"
            )
            return
        }

        let adoptDelay = lastPushToStartSuccessAt.map { Int(Date().timeIntervalSince($0)) }
        let delayDescription = adoptDelay.map { "\($0)s after last push-to-start success" } ?? "no prior push-to-start this session"
        let totalActivities = Activity<GlucoseLiveActivityAttributes>.activities.count
        let staleDate = activity.content.staleDate
        let staleDesc = staleDate.map { String(format: "%.0f", $0.timeIntervalSinceNow) + "s" } ?? "nil"
        let incomingSeq = activity.content.state.seq
        LogManager.shared.log(
            category: .general,
            message: "[LA] adopt: id=\(activity.id) seq=\(incomingSeq) staleIn=\(staleDesc) totalActivities=\(totalActivities) (\(delayDescription))"
        )
        lastPushToStartSuccessAt = nil
        pushToStartSendsWithoutAdoption = 0
        // The new LA is confirmed — clear any post-send backoff so a legitimate
        // near-term renewal isn't silently blocked by the 5-minute base interval.
        Storage.shared.laPushToStartBackoff.value = 0

        // If we already have a current activity and this is a different one, it's likely
        // the new push-to-start LA replacing an old one. End the old, then bind the new.
        if let old = current, old.id != activity.id {
            LogManager.shared.log(
                category: .general,
                message: "[LA] activityUpdates: replacing old=\(old.id) with new=\(activity.id)"
            )
            let oldActivity = old
            Task {
                await oldActivity.end(nil, dismissalPolicy: .immediate)
            }
        } else {
            LogManager.shared.log(
                category: .general,
                message: "[LA] activityUpdates: adopting new activity id=\(activity.id) (no prior current)"
            )
        }
        // Fresh deadline — push-to-start-initiated LAs reset the 8-hour clock.
        Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold
        Storage.shared.laRenewalFailed.value = false
        cancelRenewalFailedNotification()
        dismissedByUser = false
        // A fresh LA invalidates any latched foreground-restart intent — the
        // condition that prompted the latch (overlay showing / renewal failed)
        // is resolved by adoption itself, so a deferred restart on the next
        // didBecomeActive would needlessly tear down the just-adopted LA.
        if pendingForegroundRestart {
            LogManager.shared.log(
                category: .general,
                message: "[LA] adoption clears stale pendingForegroundRestart (LA already replaced via push-to-start)"
            )
            pendingForegroundRestart = false
        }
        bind(to: activity, logReason: "push-to-start-adopt")
    }

    /// Fires before the app loses focus (lock screen, home button, etc.).
    /// Cancels any pending debounced refresh and pushes the latest snapshot
    /// directly to the Live Activity while the app is still foreground-active,
    /// ensuring the LA is up to date the moment the lock screen appears.
    @objc private func handleWillResignActive() {
        guard Storage.shared.laEnabled.value, let activity = current else { return }

        refreshWorkItem?.cancel()
        refreshWorkItem = nil

        let provider = StorageCurrentGlucoseStateProvider()
        guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return }

        LAAppGroupSettings.setThresholds(
            lowMgdl: Storage.shared.lowLine.value,
            highMgdl: Storage.shared.highLine.value,
        )
        GlucoseSnapshotStore.shared.save(snapshot)

        seq += 1
        let nextSeq = seq
        let state = GlucoseLiveActivityAttributes.ContentState(
            snapshot: snapshot,
            seq: nextSeq,
            reason: "resign-active",
            producedAt: Date(),
        )
        let content = ActivityContent(
            state: state,
            staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
            relevanceScore: 100.0,
        )

        Task {
            // Direct ActivityKit update — app is still active at this point.
            await activity.update(content)
            LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true)
            // Also send APNs so the extension receives the latest token-based update.
            if let token = pushToken {
                await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
            }
        }
    }

    @objc private func handleDidBecomeActive() {
        guard Storage.shared.laEnabled.value else { return }
        let appState = UIApplication.shared.applicationState.rawValue
        let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
        if pendingForegroundRestart {
            pendingForegroundRestart = false
            LogManager.shared.log(
                category: .general,
                message: "[LA] didBecomeActive: running deferred foreground restart (appState=\(appState), activities=\(existing))"
            )
            performForegroundRestart()
            return
        }
        LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: startFromCurrentState (appState=\(appState), activities=\(existing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser))", isDebug: true)
        Task { @MainActor in
            self.startFromCurrentState()
        }
    }

    @objc private func handleForeground() {
        guard Storage.shared.laEnabled.value else { return }

        let renewalFailed = Storage.shared.laRenewalFailed.value
        let renewBy = Storage.shared.laRenewBy.value
        let now = Date().timeIntervalSince1970
        let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
        let stuckSends = pushToStartSendsWithoutAdoption
        let pushToStartLooksStuck = stuckSends >= LiveActivityManager.pushToStartForceRestartThreshold
        let appState = UIApplication.shared.applicationState.rawValue
        let existing = Activity<GlucoseLiveActivityAttributes>.activities.count

        LogManager.shared.log(
            category: .general,
            message: "[LA] foreground: appState=\(appState), activities=\(existing), renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now), pushToStartSendsWithoutAdoption=\(stuckSends)"
        )

        guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
            LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
            return
        }

        if pushToStartLooksStuck {
            // Reset the counter now so we don't re-trigger on every foreground
            // entry until the next round of silently-failed sends actually
            // builds up again. The restart itself ends the current LA and
            // starts a fresh one, which (per Apple's docs) should cause iOS to
            // emit a new pushToStartToken — the workaround for FB21158660.
            pushToStartSendsWithoutAdoption = 0
            LogManager.shared.log(
                category: .general,
                message: "[LA] foreground: push-to-start looks stuck (sendsWithoutAdoption=\(stuckSends) ≥ \(LiveActivityManager.pushToStartForceRestartThreshold)) — forcing local restart to nudge token rotation"
            )
        } else {
            LogManager.shared.log(
                category: .general,
                message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
            )
        }

        // willEnterForegroundNotification fires before the scene reaches
        // foregroundActive — Activity.request() returns `visibility` during
        // this window. Defer the actual restart to didBecomeActive.
        pendingForegroundRestart = true
        LogManager.shared.log(
            category: .general,
            message: "[LA] foreground: scheduling restart on next didBecomeActive"
        )
    }

    private func performForegroundRestart() {
        // Re-check the conditions that latched the intent. The latch can outlive its
        // trigger — e.g. if the user briefly foregrounds the app while the renewal
        // overlay is up, then backgrounds before didBecomeActive runs, the background
        // renewal can replace the LA before the next foreground entry. By the time
        // didBecomeActive eventually fires, the freshly-renewed LA is healthy and a
        // restart would be gratuitous.
        let renewalFailed = Storage.shared.laRenewalFailed.value
        let renewBy = Storage.shared.laRenewBy.value
        let now = Date().timeIntervalSince1970
        let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
        let pushToStartLooksStuck = pushToStartSendsWithoutAdoption >= LiveActivityManager.pushToStartForceRestartThreshold
        guard renewalFailed || overlayIsShowing || pushToStartLooksStuck else {
            LogManager.shared.log(
                category: .general,
                message: "[LA] deferred foreground restart skipped — conditions no longer hold (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), pushToStartLooksStuck=\(pushToStartLooksStuck))"
            )
            return
        }

        // Mark restart intent BEFORE clearing storage flags, so any late .dismissed
        // from the old activity is never misclassified as a user swipe.
        endingForRestart = true
        dismissedByUser = false
        nextStartReasonOverride = "deferred-foreground-restart"

        // Stop any observers/tasks tied to the previous activity instance. In the
        // current=nil branch below, the old observer can otherwise deliver a late
        // .dismissed and poison dismissedByUser.
        updateTask?.cancel()
        updateTask = nil
        tokenObservationTask?.cancel()
        tokenObservationTask = nil
        stateObserverTask?.cancel()
        stateObserverTask = nil
        pushToken = nil

        // Clear renewal state so the new snapshot does not show the renewal overlay.
        Storage.shared.laRenewBy.value = 0
        Storage.shared.laRenewalFailed.value = false
        cancelRenewalFailedNotification()

        guard let activity = current else {
            LogManager.shared.log(
                category: .general,
                message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart"
            )
            current = nil

            Task {
                for activity in Activity<GlucoseLiveActivityAttributes>.activities {
                    await activity.end(nil, dismissalPolicy: .immediate)
                }
                await MainActor.run {
                    self.dismissedByUser = false
                    self.startFromCurrentState(cleanupOrphans: false)
                    LogManager.shared.log(
                        category: .general,
                        message: "[LA] foreground restart: fresh LA started after ending unbound existing activity"
                    )
                }
            }
            return
        }

        current = nil

        Task {
            await activity.end(nil, dismissalPolicy: .immediate)
            await MainActor.run {
                self.dismissedByUser = false
                self.startFromCurrentState(cleanupOrphans: false)
                LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry")
            }
        }
    }

    @objc private func handleBackgroundAudioFailed() {
        guard Storage.shared.laEnabled.value, current != nil else { return }
        // The background audio session has permanently failed — the app will lose its
        // background keep-alive. Immediately push the renewal overlay so the user sees
        // "Tap to update" on the lock screen and knows to foreground the app.
        LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay")
        Storage.shared.laRenewBy.value = Date().timeIntervalSince1970
        refreshFromCurrentState(reason: "audio-session-failed")
    }

    private func shouldRestartBecauseExtensionLooksStuck() -> Bool {
        guard Storage.shared.laEnabled.value else { return false }
        guard !dismissedByUser else { return false }

        guard let activity = current ?? Activity<GlucoseLiveActivityAttributes>.activities.first else {
            return false
        }

        let now = Date().timeIntervalSince1970
        let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
        if staleDatePassed {
            LogManager.shared.log(
                category: .general,
                message: "[LA] liveness check: staleDate already passed"
            )
            return true
        }

        let expectedSeq = activity.content.state.seq
        let seenSeq = LALivenessStore.lastExtensionSeq
        let lastSeenAt = LALivenessStore.lastExtensionSeenAt
        let lastProducedAt = LALivenessStore.lastExtensionProducedAt

        let extensionHasNeverCheckedIn = lastSeenAt <= 0
        let extensionLooksBehind = seenSeq < expectedSeq
        let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace)

        LogManager.shared.log(
            category: .general,
            message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)",
            isDebug: true
        )

        // Conservative rule:
        // only suspect "stuck" if the extension is both behind AND has not checked in recently.
        return extensionLooksBehind && noRecentExtensionTouch
    }

    static let renewalThreshold: TimeInterval = 7.5 * 3600
    static let renewalWarning: TimeInterval = 30 * 60
    static let extensionLivenessGrace: TimeInterval = 15 * 60

    /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429,
    /// capped at `pushToStartMaxBackoff`. Reset to base after a successful send.
    private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min
    private static let pushToStartMaxBackoff: TimeInterval = 3600 // 60 min
    /// When a successful APNs push-to-start does not result in an `activityUpdates`
    /// adoption, count those orphaned sends. After this threshold, the next
    /// foreground entry forces a local restart to nudge iOS to issue a new
    /// pushToStartToken — Apple FB21158660 workaround. Set to 4 (not 2) to avoid
    /// false positives on slow connections where the activityUpdates delivery lags.
    private static let pushToStartForceRestartThreshold: Int = 4
    /// Polling timeout for the push-to-start token to arrive after a fresh install.
    /// `pushToStartTokenUpdates` typically delivers within a couple of seconds.
    private static let pushToStartTokenWaitTimeout: TimeInterval = 5
    private static let pushToStartTokenPollInterval: TimeInterval = 0.5
    /// Delay before the single automatic retry when the push-to-start token is not
    /// yet available after the initial wait. The token is almost always en route
    /// and arrives within a few seconds of the first request.
    private static let pushToStartTokenRetryDelay: TimeInterval = 10

    private(set) var current: Activity<GlucoseLiveActivityAttributes>?
    private var stateObserverTask: Task<Void, Never>?
    private var updateTask: Task<Void, Never>?
    private var seq: Int = 0
    private var lastUpdateTime: Date?
    private var pushToken: String?
    private var tokenObservationTask: Task<Void, Never>?
    private var refreshWorkItem: DispatchWorkItem?
    /// Set when the user manually swipes away the LA. Blocks auto-restart until
    /// an explicit user action (Restart button, App Intent) clears it.
    /// In-memory only — resets to false on app relaunch, so a kill + relaunch
    /// starts fresh as expected.
    private var dismissedByUser = false
    /// Set to true immediately before we call activity.end() as part of a planned restart.
    /// Cleared after the restart completes. The state observer checks this flag so that
    /// a .dismissed delivery triggered by our own end() call is never misclassified as a
    /// user swipe — regardless of the order in which the MainActor executes the two writes.
    private var endingForRestart = false
    /// Set by handleForeground() when the renewal window has been detected.
    /// The actual end+restart is run from handleDidBecomeActive() because
    /// Activity.request() returns `visibility` during willEnterForeground.
    private var pendingForegroundRestart = false
    /// Observes `pushToStartTokenUpdates` (iOS 17.2+) and persists the token.
    /// Long-lived — started once at init and never cancelled.
    private var pushToStartObservationTask: Task<Void, Never>?
    /// Observes `Activity<>.activityUpdates` so activities started out-of-band
    /// (push-to-start) are adopted automatically.
    private var activityUpdatesObservationTask: Task<Void, Never>?
    /// Timestamp of the last successful push-to-start APNs dispatch. Used to log
    /// the delay until iOS delivers the new activity via `activityUpdates`. If
    /// adoption never happens, a growing gap here is the fingerprint.
    private var lastPushToStartSuccessAt: Date?
    /// Number of consecutive successful push-to-start APNs sends that have NOT
    /// been followed by an `activityUpdates` adoption. When this reaches
    /// `pushToStartForceRestartThreshold`, the next foreground entry forces a
    /// local restart even outside the renewal window — ending the existing LA
    /// and starting a fresh one is the only known way to nudge iOS to issue a
    /// new `pushToStartToken` when the current one has gone silent
    /// (Apple FB21158660).
    private var pushToStartSendsWithoutAdoption: Int = 0
    /// Single-shot override for the next push-to-start reason tag. Consumed by
    /// `startIfNeeded`. Lets the deferred-foreground-restart path tag its
    /// push-to-start with a distinct label instead of "user-start", which made
    /// the 8:25 stale-latch event indistinguishable from a real user start in
    /// the log.
    private var nextStartReasonOverride: String?

    // MARK: - Public API

    @MainActor
    func startIfNeeded() {
        let authorized = ActivityAuthorizationInfo().areActivitiesEnabled
        let existingCount = Activity<GlucoseLiveActivityAttributes>.activities.count
        LogManager.shared.log(
            category: .general,
            message: "[LA] startIfNeeded: authorized=\(authorized), activities=\(existingCount), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), laEnabled=\(Storage.shared.laEnabled.value)",
            isDebug: true
        )
        guard authorized else {
            LogManager.shared.log(category: .general, message: "Live Activity not authorized")
            return
        }

        let startReason = nextStartReasonOverride ?? "user-start"
        nextStartReasonOverride = nil

        if #available(iOS 17.2, *) {
            // iOS 17.2+ uses push-to-start for every creation path. If an
            // activity is already running and not stale we adopt/reuse it
            // (covers warm starts where the LA survived a relaunch); only
            // truly new starts dispatch APNs.
            if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
                let renewBy = Storage.shared.laRenewBy.value
                let now = Date().timeIntervalSince1970
                let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
                let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
                let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
                if !needsRestart {
                    bind(to: existing, logReason: "reuse")
                    Storage.shared.laRenewalFailed.value = false
                    return
                }
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
                )
                attemptPushToStartCreate(reason: startReason, oldActivity: existing)
                return
            }
            attemptPushToStartCreate(reason: startReason, oldActivity: nil)
        } else {
            startIfNeededLegacy()
        }
    }

    /// Pre-17.2 path (iOS 16.6 – 17.1). Identical to dev's `startIfNeeded` —
    /// Activity.request() for everything. Removable when the deployment target
    /// reaches 17.2.
    @MainActor
    private func startIfNeededLegacy() {
        if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
            // Before reusing, check whether this activity needs a restart. This covers cold
            // starts (app was killed while the overlay was showing — willEnterForeground is
            // never sent, so handleForeground never runs) and any other path that lands here
            // without first going through handleForeground.
            let renewBy = Storage.shared.laRenewBy.value
            let now = Date().timeIntervalSince1970
            let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
            let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
            let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed

            if needsRestart {
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
                )

                endingForRestart = true
                dismissedByUser = false

                Storage.shared.laRenewBy.value = 0
                Storage.shared.laRenewalFailed.value = false
                cancelRenewalFailedNotification()

                Task {
                    await existing.end(nil, dismissalPolicy: .immediate)
                    await MainActor.run { self.startIfNeededLegacy() }
                }
                return
            }

            bind(to: existing, logReason: "reuse")
            Storage.shared.laRenewalFailed.value = false
            return
        }

        do {
            let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")

            let provider = StorageCurrentGlucoseStateProvider()
            let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
                ?? GlucoseSnapshotStore.shared.load()
                ?? GlucoseSnapshot(
                    glucose: 0,
                    delta: 0,
                    trend: .unknown,
                    updatedAt: Date(),
                    iob: nil,
                    cob: nil,
                    projected: nil,
                    unit: .mgdl,
                    isNotLooping: false,
                )

            let initialState = GlucoseLiveActivityAttributes.ContentState(
                snapshot: seedSnapshot,
                seq: 0,
                reason: "start",
                producedAt: Date(),
            )

            let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
            let content = ActivityContent(state: initialState, staleDate: renewDeadline)
            LALivenessStore.clear()
            let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)

            bind(to: activity, logReason: "start-new")
            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
            Storage.shared.laRenewalFailed.value = false
            LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
        } catch {
            let ns = error as NSError
            let scene = isAppVisibleForLiveActivityStart()
            LogManager.shared.log(
                category: .general,
                message: "Live Activity failed to start: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), sceneActive=\(scene), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
            )
        }
    }

    /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
    /// up to 3 s) so it clears from the lock screen before the process exits.
    /// Does not clear laEnabled — the user's preference is preserved for relaunch.
    func endOnTerminate() {
        guard let activity = current else { return }
        // Flag the end as system-initiated so the state observer does not
        // classify the resulting `.dismissed` as a user swipe (laRenewBy is
        // cleared below, which would otherwise make pastDeadline=false).
        endingForRestart = true
        current = nil
        Storage.shared.laRenewBy.value = 0
        LALivenessStore.clear()
        let semaphore = DispatchSemaphore(value: 0)
        Task.detached {
            await activity.end(nil, dismissalPolicy: .immediate)
            semaphore.signal()
        }
        _ = semaphore.wait(timeout: .now() + 3)
        LogManager.shared.log(category: .general, message: "[LA] ended on app terminate")
    }

    func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) {
        updateTask?.cancel()
        updateTask = nil

        guard let activity = current else { return }

        Task {
            let finalState = GlucoseLiveActivityAttributes.ContentState(
                snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot(
                    glucose: 0,
                    delta: 0,
                    trend: .unknown,
                    updatedAt: Date(),
                    iob: nil,
                    cob: nil,
                    projected: nil,
                    unit: .mgdl,
                    isNotLooping: false,
                ),
                seq: seq,
                reason: "end",
                producedAt: Date(),
            )

            let content = ActivityContent(state: finalState, staleDate: nil)
            await activity.end(content, dismissalPolicy: dismissalPolicy)

            LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true)

            if current?.id == activity.id {
                current = nil
                Storage.shared.laRenewBy.value = 0
                LALivenessStore.clear()
            }
        }
    }

    /// Ends all running Live Activities and starts a fresh one from the current state.
    /// Intended for the "Restart Live Activity" button and the AppIntent.
    @MainActor
    func forceRestart() {
        guard Storage.shared.laEnabled.value else { return }
        LogManager.shared.log(category: .general, message: "[LA] forceRestart called")
        // Mark as system-initiated so any residual `.dismissed` delivered from
        // the cancelled state observer stream cannot flip dismissedByUser=true
        // and spoil the freshly started LA.
        endingForRestart = true
        dismissedByUser = false
        Storage.shared.laRenewBy.value = 0
        Storage.shared.laRenewalFailed.value = false
        // The user explicitly asked for a fresh LA — clear any push-to-start
        // backoff that would otherwise rate-limit the Restart button silently.
        Storage.shared.laLastPushToStartAt.value = 0
        Storage.shared.laPushToStartBackoff.value = 0
        pushToStartSendsWithoutAdoption = 0
        LALivenessStore.clear()
        cancelRenewalFailedNotification()
        current = nil
        updateTask?.cancel(); updateTask = nil
        tokenObservationTask?.cancel(); tokenObservationTask = nil
        stateObserverTask?.cancel(); stateObserverTask = nil
        pushToken = nil
        Task {
            for activity in Activity<GlucoseLiveActivityAttributes>.activities {
                await activity.end(nil, dismissalPolicy: .immediate)
            }
            await MainActor.run {
                self.startFromCurrentState(cleanupOrphans: false)
                LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted")
            }
        }
    }

    @MainActor
    func startFromCurrentState(cleanupOrphans: Bool = false) {
        guard Storage.shared.laEnabled.value, !dismissedByUser else { return }

        if cleanupOrphans {
            endOrphanedActivities()
        }

        let provider = StorageCurrentGlucoseStateProvider()
        if let snapshot = GlucoseSnapshotBuilder.build(from: provider) {
            LAAppGroupSettings.setThresholds(
                lowMgdl: Storage.shared.lowLine.value,
                highMgdl: Storage.shared.highLine.value,
            )
            LAAppGroupSettings.setDisplayName(
                Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
                show: Storage.shared.showDisplayName.value
            )
            GlucoseSnapshotStore.shared.save(snapshot)
        }
        startIfNeeded()
    }

    func refreshFromCurrentState(reason: String) {
        // No LA guard here — Watch and store must update regardless of LA state.
        // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh.
        refreshWorkItem?.cancel()
        let workItem = DispatchWorkItem { [weak self] in
            self?.performRefresh(reason: reason)
        }
        refreshWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem)
    }

    // MARK: - Renewal

    /// Requests a fresh Live Activity to replace the current one when the renewal
    /// deadline has passed, working around Apple's 8-hour maximum LA lifetime.
    /// Returns true if renewal was performed (caller should return early).
    private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool {
        guard let oldActivity = current else { return false }

        let renewBy = Storage.shared.laRenewBy.value
        guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false }

        let overdueBy = Date().timeIntervalSince1970 - renewBy
        LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")

        if #available(iOS 17.2, *) {
            // iOS 17.2+: renewal goes through push-to-start. The dispatch hops
            // to MainActor and returns immediately; adoption (or failure) lands
            // in the observer. Return true so performRefresh stops processing
            // this tick.
            Task { @MainActor [weak self] in
                self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
            }
            return true
        } else {
            return attemptLegacyRenewal(snapshot: snapshot, oldActivity: oldActivity)
        }
    }

    /// Pre-17.2 renewal (iOS 16.6 – 17.1): foreground Activity.request, mark
    /// renewal-failed if it throws. Removable when the deployment target
    /// reaches 17.2.
    private func attemptLegacyRenewal(
        snapshot: GlucoseSnapshot,
        oldActivity: Activity<GlucoseLiveActivityAttributes>
    ) -> Bool {
        let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
        let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")

        // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
        // fresh deadline so no overlay is needed from the first frame. We pass the
        // deadline as staleDate to ActivityContent below, not to Storage yet; Storage
        // is only updated after Activity.request succeeds so a crash between the two
        // can't leave the deadline permanently stuck in the future.
        let freshSnapshot = snapshot.withRenewalOverlay(false)

        let state = GlucoseLiveActivityAttributes.ContentState(
            snapshot: freshSnapshot,
            seq: seq,
            reason: "renew",
            producedAt: Date(),
        )
        let content = ActivityContent(state: state, staleDate: renewDeadline)

        do {
            let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)

            Task {
                await oldActivity.end(nil, dismissalPolicy: .immediate)
            }

            updateTask?.cancel()
            updateTask = nil
            tokenObservationTask?.cancel()
            tokenObservationTask = nil
            stateObserverTask?.cancel()
            stateObserverTask = nil
            pushToken = nil

            // Write deadline only on success — avoids a stuck future deadline if we crash
            // between the write and the Activity.request call.
            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
            bind(to: newActivity, logReason: "renew")
            Storage.shared.laRenewalFailed.value = false
            cancelRenewalFailedNotification()
            GlucoseSnapshotStore.shared.save(freshSnapshot)
            LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
            return true
        } catch {
            // Renewal failed — deadline was never written, so no rollback needed.
            let isFirstFailure = !Storage.shared.laRenewalFailed.value
            Storage.shared.laRenewalFailed.value = true
            let ns = error as NSError
            LogManager.shared.log(
                category: .general,
                message: "[LA] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
            )
            if isFirstFailure {
                scheduleRenewalFailedNotification()
            }
            return false
        }
    }

    // MARK: - Push-to-start (iOS 17.2+)

    /// Single creation path for iOS 17.2+. Handles initial start, renewal, and
    /// forced restart. Verifies token + APNs credentials, applies backoff, then
    /// dispatches the APNs push-to-start call. The old activity is only ended
    /// after a confirmed successful send, preserving it if the send fails.
    /// Adoption is delivered via the `activityUpdates` observer —
    /// `handlePushToStartResult` only updates backoff/state.
    @available(iOS 17.2, *)
    @MainActor
    private func attemptPushToStartCreate(
        reason: String,
        oldActivity: Activity<GlucoseLiveActivityAttributes>?,
        snapshot: GlucoseSnapshot? = nil
    ) {
        // Validate APNs credentials up-front — push-to-start is the ONLY transport
        // on iOS 17.2+, so missing/invalid creds mean the LA will never display.
        let keyId = Storage.shared.lfKeyId.value
        let apnsKey = Storage.shared.lfApnsKey.value
        guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
            LogManager.shared.log(
                category: .general,
                message: "[LA] push-to-start (\(reason)) blocked — APNs credentials missing or invalid (keyId valid=\(APNsCredentialValidator.isValidKeyId(keyId)), apnsKey valid=\(APNsCredentialValidator.isValidApnsKey(apnsKey)))"
            )
            scheduleApnsCredentialsMissingNotification()
            return
        }

        let now = Date().timeIntervalSince1970
        let lastAt = Storage.shared.laLastPushToStartAt.value
        let backoff = Storage.shared.laPushToStartBackoff.value
        if lastAt > 0, now < lastAt + backoff {
            let wait = Int(lastAt + backoff - now)
            LogManager.shared.log(
                category: .general,
                message: "[LA] push-to-start (\(reason)) rate-limited: next allowed in \(wait)s (backoff=\(Int(backoff))s)"
            )
            return
        }

        // Build snapshot if caller didn't supply one (initial start path).
        let workingSnapshot: GlucoseSnapshot = {
            if let snapshot { return snapshot }
            let provider = StorageCurrentGlucoseStateProvider()
            return GlucoseSnapshotBuilder.build(from: provider)
                ?? GlucoseSnapshotStore.shared.load()
                ?? GlucoseSnapshot(
                    glucose: 0,
                    delta: 0,
                    trend: .unknown,
                    updatedAt: Date(),
                    iob: nil,
                    cob: nil,
                    projected: nil,
                    unit: .mgdl,
                    isNotLooping: false,
                )
        }()

        Task { [weak self] in
            guard let self else { return }
            await self.dispatchPushToStart(
                reason: reason,
                oldActivity: oldActivity,
                snapshot: workingSnapshot
            )
        }
    }

    @available(iOS 17.2, *)
    private func dispatchPushToStart(
        reason: String,
        oldActivity: Activity<GlucoseLiveActivityAttributes>?,
        snapshot: GlucoseSnapshot,
        isRetry: Bool = false
    ) async {
        // Wait briefly for the push-to-start token to arrive — covers the
        // fresh-install case where the user toggles LA on before iOS has
        // delivered the first token via pushToStartTokenUpdates.
        var token = Storage.shared.laPushToStartToken.value
        if token.isEmpty {
            let pollIntervalNs = UInt64(LiveActivityManager.pushToStartTokenPollInterval * 1_000_000_000)
            let maxAttempts = Int(LiveActivityManager.pushToStartTokenWaitTimeout / LiveActivityManager.pushToStartTokenPollInterval)
            for attempt in 1 ... maxAttempts {
                try? await Task.sleep(nanoseconds: pollIntervalNs)
                token = Storage.shared.laPushToStartToken.value
                if !token.isEmpty {
                    LogManager.shared.log(
                        category: .general,
                        message: "[LA] push-to-start (\(reason)) token arrived after \(attempt) poll(s)"
                    )
                    break
                }
            }
        }
        guard !token.isEmpty else {
            if isRetry {
                // Token still absent after retry — give up and notify the user.
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] push-to-start (\(reason)) aborted — no token after retry (iOS hasn't issued one yet)"
                )
                await MainActor.run { self.schedulePushToStartTokenMissingNotification() }
            } else {
                // Token likely en route — wait briefly and make a single automatic
                // retry before surfacing an error to the user.
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] push-to-start (\(reason)) no token after \(Int(LiveActivityManager.pushToStartTokenWaitTimeout))s — retrying in \(Int(LiveActivityManager.pushToStartTokenRetryDelay))s"
                )
                try? await Task.sleep(nanoseconds: UInt64(LiveActivityManager.pushToStartTokenRetryDelay * 1_000_000_000))
                await dispatchPushToStart(reason: reason, oldActivity: oldActivity, snapshot: snapshot, isRetry: true)
            }
            return
        }

        // Record attempt time up-front so two refresh ticks can't double-fire.
        await MainActor.run {
            Storage.shared.laLastPushToStartAt.value = Date().timeIntervalSince1970
        }

        let nextSeq = await MainActor.run { () -> Int in
            self.seq += 1
            return self.seq
        }
        let freshSnapshot = snapshot.withRenewalOverlay(false)
        let state = GlucoseLiveActivityAttributes.ContentState(
            snapshot: freshSnapshot,
            seq: nextSeq,
            reason: reason,
            producedAt: Date(),
        )
        let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)

        let tail = String(token.suffix(8))
        LogManager.shared.log(
            category: .general,
            message: "[LA] push-to-start (\(reason)) firing token=…\(tail) seq=\(nextSeq) staleIn=\(Int(staleDate.timeIntervalSinceNow))s"
        )

        let sendStart = Date()
        let result = await APNSClient.shared.sendLiveActivityStart(
            pushToStartToken: token,
            attributesTitle: "LoopFollow",
            state: state,
            staleDate: staleDate,
        )
        let elapsedMs = Int(Date().timeIntervalSince(sendStart) * 1000)
        LogManager.shared.log(
            category: .general,
            message: "[LA] push-to-start (\(reason)) APNs round-trip result=\(result) elapsed=\(elapsedMs)ms"
        )

        // End the old activity only after a confirmed successful send — if the
        // send fails the user keeps their existing LA rather than losing data
        // with nothing to replace it.
        if result == .success, let oldActivity {
            LogManager.shared.log(
                category: .general,
                message: "[LA] push-to-start (\(reason)) send succeeded — ending oldActivity=\(oldActivity.id)"
            )
            await oldActivity.end(nil, dismissalPolicy: .immediate)
        }

        await MainActor.run {
            self.handlePushToStartResult(result, reason: reason)
        }
    }

    @available(iOS 17.2, *)
    @MainActor
    private func handlePushToStartResult(
        _ result: APNSClient.PushToStartResult,
        reason: String
    ) {
        switch result {
        case .success:
            // Adoption of the new LA runs via `activityUpdates` observation,
            // which ends the old activity, resets the renewal deadline and
            // clears `laRenewalFailed`. Apply base backoff so refresh ticks
            // between now and adoption don't re-fire push-to-start.
            Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
            lastPushToStartSuccessAt = Date()
            pushToStartSendsWithoutAdoption += 1
            LogManager.shared.log(
                category: .general,
                message: "[LA] push-to-start (\(reason)) succeeded — awaiting activityUpdates to adopt new LA (backoff=\(Int(LiveActivityManager.pushToStartBaseBackoff))s, sendsWithoutAdoption=\(pushToStartSendsWithoutAdoption))"
            )
        case .rateLimited:
            let currentBackoff = Storage.shared.laPushToStartBackoff.value
            let next = min(
                LiveActivityManager.pushToStartMaxBackoff,
                max(LiveActivityManager.pushToStartBaseBackoff, currentBackoff * 2)
            )
            Storage.shared.laPushToStartBackoff.value = next
            LogManager.shared.log(
                category: .general,
                message: "[LA] push-to-start (\(reason)) 429 — backoff raised to \(Int(next))s"
            )
            if reason == "renew" { markRenewalFailedFromBackground() }
        case .tokenInvalid:
            // Clear the stored token so the next `pushToStartTokenUpdates`
            // delivery overwrites it. Reset backoff — no point holding off
            // while we wait for iOS to reissue.
            Storage.shared.laPushToStartToken.value = ""
            Storage.shared.laPushToStartBackoff.value = 0
            LogManager.shared.log(
                category: .general,
                message: "[LA] push-to-start (\(reason)) token invalid — cleared, awaiting new token"
            )
            if reason == "renew" { markRenewalFailedFromBackground() }
        case .failed:
            let currentBackoff = Storage.shared.laPushToStartBackoff.value
            if currentBackoff < LiveActivityManager.pushToStartBaseBackoff {
                Storage.shared.laPushToStartBackoff.value = LiveActivityManager.pushToStartBaseBackoff
            }
            if reason == "renew" { markRenewalFailedFromBackground() }
        }
    }

    /// Background renewal couldn't restart the LA via push-to-start (rate-limited,
    /// invalid token, etc.). Mark the state so the renewal overlay shows on the
    /// lock screen, and post a local notification on the first failure so the
    /// user knows to foreground the app.
    private func markRenewalFailedFromBackground() {
        let isFirstFailure = !Storage.shared.laRenewalFailed.value
        Storage.shared.laRenewalFailed.value = true
        LogManager.shared.log(
            category: .general,
            message: "[LA] push-to-start renewal failed — renewal marked failed"
        )
        if isFirstFailure {
            scheduleRenewalFailedNotification()
        }
    }

    private func performRefresh(reason: String) {
        let provider = StorageCurrentGlucoseStateProvider()
        guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
            return
        }
        LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true)
        let fingerprint =
            "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " +
            "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " +
            "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)"
        LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true)

        // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so.
        if renewIfNeeded(snapshot: snapshot) { return }

        if snapshot.showRenewalOverlay {
            LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible")
        }

        let now = Date()
        let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast)
        let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60
        // Capture dedup result BEFORE saving so the store comparison is valid.
        let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot

        // Store + Watch: always update, independent of LA state.
        LAAppGroupSettings.setThresholds(
            lowMgdl: Storage.shared.lowLine.value,
            highMgdl: Storage.shared.highLine.value,
        )
        GlucoseSnapshotStore.shared.save(snapshot)
        // WatchConnectivityManager.shared.send(snapshot: snapshot)

        // LA update: gated on LA being active, snapshot having changed, and activities enabled.
        if !Storage.shared.laEnabled.value {
            LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — laEnabled=false reason=\(reason)", isDebug: true)
            return
        }
        if dismissedByUser {
            LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)")
            return
        }
        guard !snapshotUnchanged || forceRefreshNeeded else { return }
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)")
            return
        }
        if current == nil,
           let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
        {
            // Skip activities already in .ended/.dismissed — those are corpses
            // (typically post-410 ends pending iOS dismissal). Binding to them
            // would clear endingForRestart and turn the eventual iOS dismissal
            // into a misclassified user swipe.
            bind(to: existing, logReason: "bind-existing")
        }
        if let _ = current {
            update(snapshot: snapshot, reason: reason)
            return
        }
        if isAppVisibleForLiveActivityStart() {
            Task { @MainActor in
                self.startIfNeeded()
                if self.current != nil {
                    self.update(snapshot: snapshot, reason: reason)
                }
            }
        } else {
            LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true)
        }
    }

    private func isAppVisibleForLiveActivityStart() -> Bool {
        let scenes = UIApplication.shared.connectedScenes
        return scenes.contains { $0.activationState == .foregroundActive }
    }

    func update(snapshot: GlucoseSnapshot, reason: String) {
        if current == nil,
           let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
        {
            bind(to: existing, logReason: "bind-existing")
        }

        guard let activity = current else { return }

        updateTask?.cancel()

        seq += 1
        let nextSeq = seq
        let activityID = activity.id

        let state = GlucoseLiveActivityAttributes.ContentState(
            snapshot: snapshot,
            seq: nextSeq,
            reason: reason,
            producedAt: Date(),
        )

        updateTask = Task { [weak self] in
            guard let self else { return }

            if activity.activityState == .ended || activity.activityState == .dismissed {
                if current?.id == activityID { current = nil }
                return
            }

            let content = ActivityContent(
                state: state,
                staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
                relevanceScore: 100.0,
            )

            if Task.isCancelled { return }

            // Dual-path update strategy:
            // - Foreground: direct ActivityKit update works reliably.
            // - Background: direct update silently fails due to the audio session
            //   limitation. APNs self-push is the only reliable delivery path.
            //   Both paths are attempted when applicable; APNs is the authoritative
            //   background mechanism.
            let isForeground = await MainActor.run {
                UIApplication.shared.applicationState == .active
            }

            if isForeground {
                await activity.update(content)
            } else {
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] update seq=\(nextSeq) — app backgrounded, direct ActivityKit update skipped, relying on APNs",
                    isDebug: true
                )
            }

            if Task.isCancelled { return }

            guard current?.id == activityID else {
                LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding")
                return
            }

            lastUpdateTime = Date()
            LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true)

            if let token = pushToken {
                await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
            } else {
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] update seq=\(nextSeq) reason=\(reason) — no push token yet, APNs skipped"
                )
            }
        }
    }

    // MARK: - Binding / Lifecycle

    /// Ends any Live Activities of this type that are not the one currently tracked.
    /// Called on app launch to clean up cards left behind by a previous crash.
    private func endOrphanedActivities() {
        for activity in Activity<GlucoseLiveActivityAttributes>.activities {
            guard activity.id != current?.id else { continue }
            let orphanID = activity.id
            Task {
                await activity.end(nil, dismissalPolicy: .immediate)
                LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)")
            }
        }
    }

    private func bind(to activity: Activity<GlucoseLiveActivityAttributes>, logReason: String) {
        if current?.id == activity.id { return }
        current = activity
        let wasEndingForRestart = endingForRestart
        dismissedByUser = false
        endingForRestart = false
        attachStateObserver(to: activity)
        LogManager.shared.log(
            category: .general,
            message: "Live Activity bound id=\(activity.id) state=\(activity.activityState) (\(logReason)) — endingForRestart cleared (was \(wasEndingForRestart))",
            isDebug: true
        )
        observePushToken(for: activity)
    }

    private func observePushToken(for activity: Activity<GlucoseLiveActivityAttributes>) {
        tokenObservationTask?.cancel()
        let activityID = activity.id
        tokenObservationTask = Task {
            for await tokenData in activity.pushTokenUpdates {
                let token = tokenData.map { String(format: "%02x", $0) }.joined()
                let previousTail = self.pushToken.map { String($0.suffix(8)) } ?? "nil"
                let tail = String(token.suffix(8))
                self.pushToken = token
                LogManager.shared.log(
                    category: .general,
                    message: "[LA] push token received id=\(activityID) token=…\(tail) (prev=…\(previousTail))"
                )
            }
        }
    }

    func handleExpiredToken() {
        let existing = Activity<GlucoseLiveActivityAttributes>.activities.count
        LogManager.shared.log(
            category: .general,
            message: "[LA] handleExpiredToken: current=\(current?.id ?? "nil"), activities=\(existing), dismissedByUser=\(dismissedByUser) — marking endingForRestart and ending"
        )
        // Mark as system-initiated so the `.dismissed` delivered by end()
        // is not classified as a user swipe — that would set dismissedByUser=true
        // and block the restart kicked off below.
        endingForRestart = true
        end()

        // Waiting for the next BG refresh is unreliable: end() nulls `current`
        // and clears laRenewBy, so renewIfNeeded short-circuits and performRefresh's
        // bind-existing path rebinds to the just-ended activity — clearing
        // endingForRestart and turning the eventual iOS dismissal into a misclassified
        // user swipe. Drive the restart synchronously instead.
        if #available(iOS 17.2, *) {
            Task { @MainActor [weak self] in
                self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
            }
        }
    }

    // MARK: - Renewal Notifications

    private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"
    private static let apnsCredentialsNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.apns.missing"
    private static let pushToStartTokenNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.token.missing"

    private func scheduleRenewalFailedNotification() {
        let content = UNMutableNotificationContent()
        content.title = "Live Activity Expiring"
        content.body = "Live Activity will expire soon. Open LoopFollow to restart."
        content.sound = .default
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
        let request = UNNotificationRequest(
            identifier: LiveActivityManager.renewalNotificationID,
            content: content,
            trigger: trigger,
        )
        UNUserNotificationCenter.current().add(request) { error in
            if let error {
                LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)")
            }
        }
        LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled")
    }

    private func cancelRenewalFailedNotification() {
        let id = LiveActivityManager.renewalNotificationID
        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
        UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
    }

    private func scheduleApnsCredentialsMissingNotification() {
        let content = UNMutableNotificationContent()
        content.title = "Live Activity Setup Needed"
        content.body = "APNs credentials are missing or invalid. Configure them in Settings → APN."
        content.sound = .default
        let request = UNNotificationRequest(
            identifier: LiveActivityManager.apnsCredentialsNotificationID,
            content: content,
            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
        )
        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
    }

    private func schedulePushToStartTokenMissingNotification() {
        let content = UNMutableNotificationContent()
        content.title = "Live Activity Could Not Start"
        content.body = "Live Activity could not start — try again in a moment."
        content.sound = .default
        let request = UNNotificationRequest(
            identifier: LiveActivityManager.pushToStartTokenNotificationID,
            content: content,
            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false),
        )
        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
    }

    private func attachStateObserver(to activity: Activity<GlucoseLiveActivityAttributes>) {
        stateObserverTask?.cancel()
        stateObserverTask = Task {
            for await state in activity.activityStateUpdates {
                LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
                if state == .ended || state == .dismissed {
                    // Capture whether this delivery is for the activity we currently track
                    // BEFORE clearing `current` below. The classifier needs this signal to
                    // distinguish a real user swipe of the foreground LA from a late
                    // .dismissed delivered by a stale observer for an activity we already
                    // ended programmatically.
                    let wasCurrentActivity = current?.id == activity.id
                    if wasCurrentActivity {
                        current = nil
                        // Do NOT clear laRenewBy here. Preserving it means handleForeground()
                        // can detect the renewal window on the next foreground event and restart
                        // automatically — whether the LA ended normally (.ended) or was
                        // system-dismissed (.dismissed). laRenewBy is only set to 0 when:
                        //   • the user explicitly swipes (below) — renewal intent cancelled
                        //   • a new LA starts (startIfNeeded writes the new deadline)
                        //   • handleForeground() clears it synchronously before restarting
                        //   • the user disables LA or calls forceRestart
                        LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true)
                    }
                    if state == .ended, wasCurrentActivity, !endingForRestart {
                        // iOS terminated the activity itself — typically the ~8h lifetime
                        // cap reached before renewal fired. The .dismissed path below
                        // already handles iOS-initiated dismissals via renewalFailed /
                        // pastDeadline, but .ended bypasses that branch entirely. Without
                        // a signal here, handleForeground() sees `renewalFailed=false` and
                        // `renewBy` still in the future, returns "no action needed", and
                        // startIfNeeded keeps re-binding the corpse — the LA stays dark
                        // until the user manually force-restarts. Mark renewalFailed so
                        // the next foreground entry runs performForegroundRestart, which
                        // sweeps any leftover ended activity and pushes a fresh one.
                        Storage.shared.laRenewalFailed.value = true
                        LogManager.shared.log(category: .general, message: "[LA] ended by iOS (not our restart) — marked renewalFailed=true, auto-restart on next foreground")
                    }
                    if state == .dismissed {
                        // Three possible sources of .dismissed — only the third blocks restart:
                        //
                        // (a) endingForRestart: our own end() during a planned restart.
                        //     Must be checked first: handleForeground() clears laRenewalFailed
                        //     and laRenewBy synchronously before calling end(), so those flags
                        //     would read as "no problem" even though we initiated the dismissal.
                        //
                        // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour
                        //     renewal logic marked it) or the renewal deadline has already passed
                        //     (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not
                        //     the user. laRenewBy is preserved so handleForeground() restarts on
                        //     the next foreground.
                        //
                        // (c) User decision: the user explicitly swiped the LA away. Block
                        //     auto-restart until forceRestart() is called. Clear laRenewBy so
                        //     handleForeground() does NOT re-enter the renewal path on the next
                        //     foreground — the renewal intent is cancelled by the user's choice.
                        //
                        // Gated on `wasCurrentActivity`: the user can only swipe the
                        // foreground LA. A .dismissed for an activity we no longer track is a
                        // stale observer (the activity was ended programmatically and iOS is
                        // just now cleaning up) — must not latch dismissedByUser=true.
                        let now = Date().timeIntervalSince1970
                        let renewBy = Storage.shared.laRenewBy.value
                        let renewalFailed = Storage.shared.laRenewalFailed.value
                        let pastDeadline = renewBy > 0 && now >= renewBy
                        LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), wasCurrent=\(wasCurrentActivity), renewBy=\(renewBy), now=\(now)")
                        if endingForRestart {
                            // (a) Our own restart — do nothing, Task handles the rest.
                            LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
                        } else if renewalFailed || pastDeadline {
                            // (b) iOS system force-dismiss — allow auto-restart on next foreground.
                            LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
                        } else if !wasCurrentActivity {
                            // (d) Stale observer for an activity we no longer track (e.g. a
                            //     post-410 end whose iOS-side dismissal landed hours later).
                            //     Not a user swipe — no flags to set.
                            LogManager.shared.log(category: .general, message: "[LA] dismissed by stale observer (id=\(activity.id) is not current) — no action")
                        } else {
                            // (c) User decision — cancel renewal intent, block auto-restart.
                            dismissedByUser = true
                            Storage.shared.laRenewBy.value = 0
                            LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart")
                        }
                    }
                }
            }
        }
    }
}

extension Notification.Name {
    /// Posted when the user taps the Live Activity or Dynamic Island.
    /// Observers navigate to the Home or Snoozer tab as appropriate.
    static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground")
}

#endif
