import SwiftUI
import AppKit
import Observation

private let refreshIntervalSeconds: UInt64 = 30
private let forceRefreshWatchdogSeconds: TimeInterval = 90
private let refreshLoopWatchdogSeconds: TimeInterval = 90
private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60
private let refreshRateLimitSeconds: TimeInterval = 5
private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30
private let statusItemWidth: CGFloat = NSStatusItem.variableLength
private let popoverWidth: CGFloat = 360
private let popoverHeight: CGFloat = 660
private let menubarTitleFontSize: CGFloat = 13

@main
struct CodeBurnApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate

    var body: some Scene {
        // The Settings scene gives us a real macOS Settings window with the
        // standard ⌘, shortcut and the menubar "Settings…" item. Provider tabs
        // (Claude today, Codex/Cursor/etc. in follow-ups) live inside SettingsView.
        Settings {
            SettingsView()
                .environment(delegate.store)
        }
    }
}

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
    private var statusItem: NSStatusItem!
    private var popover: NSPopover!
    private var rightClickMonitor: Any?
    private var lastContextMenuPresentedAt: Date = .distantPast
    fileprivate let store = AppStore()
    let updateChecker = UpdateChecker()
    /// True while the displays are asleep. Refresh ticks skip spawning
    /// entirely then: nobody can see the menubar, and every fetch is a full
    /// Node process (#647).
    private var displayAsleep = false
    /// Bounds status staleness under App Nap: the 30s timer can be coalesced
    /// once the app naps (no permanent activity assertion anymore, #647), so
    /// this system-scheduled activity guarantees a tick attempt every few
    /// minutes. It goes through the same cadence gate as every other tick.
    private var napBackstop: NSBackgroundActivityScheduler?
    private var pendingRefreshWork: DispatchWorkItem?
    private var refreshTimer: DispatchSourceTimer?
    private var forceRefreshTask: Task<Void, Never>?
    private var forceRefreshStartedAt: Date?
    private var forceRefreshGeneration: UInt64 = 0
    private var statusPayloadRefreshTask: Task<Void, Never>?
    private var statusPayloadRefreshStartedAt: Date?
    private var statusPayloadRefreshGeneration: UInt64 = 0
    private var manualRefreshTask: Task<Void, Never>?
    private var manualRefreshGeneration: UInt64 = 0
    private var claudeQuotaRefreshTask: Task<Bool, Never>?
    private var codexQuotaRefreshTask: Task<Bool, Never>?
    private var refreshLoopHeartbeatAt: Date = .distantPast

    func applicationWillTerminate(_ notification: Notification) {
        if let monitor = rightClickMonitor {
            NSEvent.removeMonitor(monitor)
            rightClickMonitor = nil
        }
    }

    func applicationWillFinishLaunching(_ notification: Notification) {
        // Set accessory policy before the app's focus chain forms. On macOS Tahoe
        // (26.x), setting it after didFinishLaunching causes ghost status items
        // because the policy gets baked into the initial focus chain.
        NSApp.setActivationPolicy(.accessory)
    }

    private func observeSubscriptionDisconnect() {
        NotificationCenter.default.addObserver(
            forName: .codeBurnSubscriptionDisconnected,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor [weak self] in
                self?.resetSubscriptionCadenceAnchor()
            }
        }
    }

    func applicationDidFinishLaunching(_ notification: Notification) {
        ProcessInfo.processInfo.automaticTerminationSupportEnabled = false
        ProcessInfo.processInfo.disableSuddenTermination()
        // Deliberately NO app-lifetime beginActivity here. A permanent
        // assertion opted the app out of App Nap forever, which kept the 30s
        // spawn loop running at full tilt on battery and with the display
        // off (#647). App Nap coalescing an idle tick is the desired
        // behavior; each in-flight refresh holds its own short .background
        // activity so a fetch is never napped mid-flight, and any user
        // interaction (popover open, wake) refreshes immediately.

        restorePersistedCurrency()
        setupStatusItem()
        setupPopover()
        observeStore()
        startRefreshLoop()
        startNapBackstop()
        setupWakeObservers()
        removeLegacyRefreshAgent()
        registerLoginItemIfNeeded()
        observeSubscriptionDisconnect()
        Task { await updateChecker.checkIfNeeded() }
    }

    private func setupWakeObservers() {
        // Pause the refresh loop while the machine is asleep. Without this,
        // Task.sleep keeps a wakeup pending across the suspension and the
        // loop tick fires the same instant the wake notifications do,
        // producing 2-3 concurrent CLI spawns within ms of every wake.
        NSWorkspace.shared.notificationCenter.addObserver(
            forName: NSWorkspace.willSleepNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor in
                self?.prepareRefreshPipelineForSleep()
            }
        }

        // didWakeNotification + screensDidWakeNotification can both fire on
        // the same wake. forceRefreshTask squashes overlap; both notifications
        // still bypass the short manual-click rate limit so a just-before-sleep
        // refresh cannot block wake recovery.
        NSWorkspace.shared.notificationCenter.addObserver(
            forName: NSWorkspace.didWakeNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor in
                self?.displayAsleep = false
                self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake")
            }
        }

        NSWorkspace.shared.notificationCenter.addObserver(
            forName: NSWorkspace.screensDidWakeNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor in
                self?.displayAsleep = false
                self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake")
            }
        }

        // Display sleep without system sleep (clamshell displays off, screen
        // saver energy settings) previously kept the full spawn cadence
        // running for hours. Skip refreshes until the screens wake.
        NSWorkspace.shared.notificationCenter.addObserver(
            forName: NSWorkspace.screensDidSleepNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor in
                self?.displayAsleep = true
            }
        }
    }

    private func prepareRefreshPipelineForSleep() {
        // Leave the timer running: the kernel pauses it during sleep, and tearing
        // it down stranded the loop whenever a wake notification was missed.
        forceRefreshTask?.cancel()
        forceRefreshTask = nil
        forceRefreshStartedAt = nil
        forceRefreshGeneration &+= 1
        manualRefreshTask?.cancel()
        manualRefreshTask = nil
        manualRefreshGeneration &+= 1
        statusPayloadRefreshTask?.cancel()
        statusPayloadRefreshTask = nil
        statusPayloadRefreshStartedAt = nil
        statusPayloadRefreshGeneration &+= 1
        store.resetLoadingState()
        lastRefreshTime = .distantPast
    }

    private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) {
        if resetLoading {
            forceRefreshTask?.cancel()
            forceRefreshTask = nil
            forceRefreshStartedAt = nil
            forceRefreshGeneration &+= 1
            manualRefreshTask?.cancel()
            manualRefreshTask = nil
            manualRefreshGeneration &+= 1
            statusPayloadRefreshTask?.cancel()
            statusPayloadRefreshTask = nil
            statusPayloadRefreshStartedAt = nil
            statusPayloadRefreshGeneration &+= 1
            store.resetRefreshState(clearCache: clearCache)
        } else {
            _ = store.clearStaleLoadingIfNeeded()
        }
        let now = Date()
        let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
        if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds {
            if refreshTimer != nil {
                NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason)
            }
            startRefreshLoop(forceQuotaOnStart: false)
        } else {
            // Manual cadence means no automatic spawns, wakes included; the
            // user refreshes via popover open or Refresh Now. The tick still
            // runs so stuck-state cleanup happens.
            runRefreshLoopTick(
                reason: reason,
                forcePayload: UsageRefreshCadence.current != .manual,
                forceQuota: false
            )
        }
    }

    // Earlier builds installed a launchd job that re-fetched data out of process.
    // macOS attributes a launchd-spawned binary differently from the LaunchServices
    // app, so it triggered its own "access data from other apps" prompt on every
    // run. Remove any such leftover job on upgrade; the in-app loop is the source of
    // truth and writes the badge backstop file itself.
    private func removeLegacyRefreshAgent() {
        let fm = FileManager.default
        let home = fm.homeDirectoryForCurrentUser.path
        let destPath = "\(home)/Library/LaunchAgents/com.codeburn.refresh.plist"
        guard fm.fileExists(atPath: destPath) else { return }

        let unload = Process()
        unload.launchPath = "/bin/launchctl"
        unload.arguments = ["unload", destPath]
        try? unload.run()
        unload.waitUntilExit()
        try? fm.removeItem(atPath: destPath)
    }

    private func registerLoginItemIfNeeded() {
        let key = "codeburn.loginItemRegistered"
        guard !UserDefaults.standard.bool(forKey: key) else { return }

        let appPath = Bundle.main.bundlePath
        let script = "tell application \"System Events\" to make login item at end with properties {path:\(appleScriptStringLiteral(appPath)), hidden:false}"

        let process = Process()
        process.launchPath = "/usr/bin/osascript"
        process.arguments = ["-e", script]
        process.standardOutput = FileHandle.nullDevice
        process.standardError = FileHandle.nullDevice

        do {
            try process.run()
            process.waitUntilExit()
            if process.terminationStatus == 0 {
                UserDefaults.standard.set(true, forKey: key)
            }
        } catch {
            NSLog("CodeBurn: Login item registration failed: \(error)")
        }
    }

    private func appleScriptStringLiteral(_ value: String) -> String {
        var escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
        escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"")
        escaped = escaped.replacingOccurrences(of: "\r", with: "")
        escaped = escaped.replacingOccurrences(of: "\n", with: "")
        return "\"\(escaped)\""
    }

    private var lastRefreshTime: Date = .distantPast

    @discardableResult
    private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
        if forceRefreshTask != nil {
            guard let started = forceRefreshStartedAt else {
                NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
                forceRefreshTask?.cancel()
                forceRefreshTask = nil
                forceRefreshGeneration &+= 1
                store.resetLoadingState()
                return true
            }
            let elapsed = now.timeIntervalSince(started)
            guard elapsed > forceRefreshWatchdogSeconds else { return false }
            NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed))
            forceRefreshTask?.cancel()
            forceRefreshTask = nil
            forceRefreshStartedAt = nil
            forceRefreshGeneration &+= 1
            store.resetLoadingState()
            return true
        }
        return false
    }

    @discardableResult
    private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
        if statusPayloadRefreshTask != nil {
            guard let started = statusPayloadRefreshStartedAt else {
                NSLog("CodeBurn: today status refresh task had no start timestamp - clearing")
                statusPayloadRefreshTask?.cancel()
                statusPayloadRefreshTask = nil
                statusPayloadRefreshGeneration &+= 1
                return true
            }
            let elapsed = now.timeIntervalSince(started)
            guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false }
            NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed))
            statusPayloadRefreshTask?.cancel()
            statusPayloadRefreshTask = nil
            statusPayloadRefreshStartedAt = nil
            statusPayloadRefreshGeneration &+= 1
            return true
        }
        return false
    }

    private func refreshStatusPayloadIfNeeded(reason: String, force: Bool = false, minAgeSeconds: TimeInterval = 0) {
        let now = Date()
        _ = clearStaleStatusPayloadRefreshIfNeeded(now: now)
        guard statusPayloadRefreshTask == nil else { return }
        guard force || store.needsStatusPayloadRefresh else { return }
        // The 30s cache TTL would otherwise defeat the battery/low-power
        // backoff through this fallback path: honor the same spawn interval
        // the main loop uses. A missing payload (nil age) always fetches.
        if !force, minAgeSeconds > 0, let age = store.menubarPayloadAgeSeconds,
           TimeInterval(age) < minAgeSeconds { return }

        let menubarPeriod = store.menubarPeriod
        if let age = store.menubarPayloadAgeSeconds, age > 120 {
            NSLog("CodeBurn: status payload stale for %ds on %@ refresh", age, reason)
        }

        statusPayloadRefreshStartedAt = now
        statusPayloadRefreshGeneration &+= 1
        let generation = statusPayloadRefreshGeneration
        statusPayloadRefreshTask = Task { [weak self] in
            guard let self else { return }
            let activity = ProcessInfo.processInfo.beginActivity(
                options: .background, reason: "CodeBurn status refresh")
            defer { ProcessInfo.processInfo.endActivity(activity) }
            await self.store.refreshQuietly(period: menubarPeriod, force: true)
            self.refreshStatusButton()
            guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return }
            self.statusPayloadRefreshTask = nil
            self.statusPayloadRefreshStartedAt = nil
        }
    }

    private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
        let now = Date()
        _ = clearStaleForceRefreshIfNeeded(now: now)
        if forceRefreshTask != nil {
            refreshStatusPayloadIfNeeded(reason: "blocked force refresh")
        }
        guard forceRefreshTask == nil else { return }
        if !bypassRateLimit {
            guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return }
        }
        lastRefreshTime = now
        forceRefreshStartedAt = now
        forceRefreshGeneration &+= 1
        let generation = forceRefreshGeneration

        forceRefreshTask = Task {
            let activity = ProcessInfo.processInfo.beginActivity(
                options: .background, reason: "CodeBurn refresh")
            defer { ProcessInfo.processInfo.endActivity(activity) }
            async let main: Void = refreshUsagePayloads(force: true, showLoading: true)
            async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota)
            _ = await main
            refreshStatusButton()
            _ = await quotas
            await MainActor.run { [weak self] in
                guard let self, self.forceRefreshGeneration == generation else { return }
                self.forceRefreshTask = nil
                self.forceRefreshStartedAt = nil
                self.lastRefreshTime = Date()
            }
        }
    }

    private func refreshUsagePayloads(force: Bool, showLoading: Bool = false) async {
        let menubarPeriod = store.menubarPeriod

        // With the popover closed, only the payloads the status item actually
        // renders are worth a Node spawn: the menubar period, plus today for
        // the context-menu summary and day views. The popover's selected key
        // is refreshed by refreshPayloadForPopoverOpen the moment it opens,
        // so a closed-popover tick never pays for it (#647).
        if !(popover?.isShown ?? false) {
            async let menubar: Void = store.refreshQuietly(period: menubarPeriod, force: force)
            async let today: Void = menubarPeriod != .today
                ? store.refreshQuietly(period: .today, force: force)
                : ()
            _ = await (menubar, today)
            return
        }

        let needsMenubarPayload = store.selectedPeriod != menubarPeriod || store.selectedProvider != .all
        let needsTodayPayload = (store.selectedPeriod != .today || store.selectedProvider != .all) && menubarPeriod != .today

        async let visible: Void = store.refresh(includeOptimize: false, force: force, showLoading: showLoading)
        async let menubar: Void = needsMenubarPayload
            ? store.refreshQuietly(period: menubarPeriod, force: force)
            : ()
        async let today: Void = needsTodayPayload
            ? store.refreshQuietly(period: .today, force: force)
            : ()
        _ = await (visible, menubar, today)
    }

    /// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where
    /// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise
    /// fetched live in the background.
    private func restorePersistedCurrency() {
        guard let code = CLICurrencyConfig.loadCode(), code != "USD" else { return }
        let symbol = CurrencyState.symbolForCode(code)
        store.currency = code

        Task {
            let cached = await FXRateCache.shared.cachedRate(for: code)
            await MainActor.run {
                CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
            }
            let fresh = await FXRateCache.shared.rate(for: code)
            if let fresh, fresh != cached {
                await MainActor.run {
                    CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
                }
            }
        }
    }

    fileprivate var lastSubscriptionRefreshAt: Date?
    fileprivate var lastCodexRefreshAt: Date?

    @discardableResult
    private func refreshLiveQuotaProgressIfDue(force: Bool = false) async -> Bool {
        let cadence = SubscriptionRefreshCadence.current
        if !force && cadence == .manual { return false }

        let now = Date()
        let threshold = force ? 0 : TimeInterval(cadence.rawValue)
        let shouldRefreshClaude = force || now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold
        let shouldRefreshCodex = force || now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) >= threshold
        guard shouldRefreshClaude || shouldRefreshCodex else { return false }

        switch (shouldRefreshClaude, shouldRefreshCodex) {
        case (true, true):
            async let claude = refreshClaudeQuotaSingleFlight()
            async let codex = refreshCodexQuotaSingleFlight()
            if await claude { lastSubscriptionRefreshAt = Date() }
            if await codex { lastCodexRefreshAt = Date() }
        case (true, false):
            if await refreshClaudeQuotaSingleFlight() {
                lastSubscriptionRefreshAt = Date()
            }
        case (false, true):
            if await refreshCodexQuotaSingleFlight() {
                lastCodexRefreshAt = Date()
            }
        case (false, false):
            break
        }
        return true
    }

    private func refreshClaudeQuotaSingleFlight() async -> Bool {
        if let task = claudeQuotaRefreshTask {
            return await task.value
        }
        let task = Task { [store] in
            await store.refreshSubscriptionReportingSuccess()
        }
        claudeQuotaRefreshTask = task
        let result = await task.value
        if claudeQuotaRefreshTask != nil {
            claudeQuotaRefreshTask = nil
        }
        return result
    }

    private func refreshCodexQuotaSingleFlight() async -> Bool {
        if let task = codexQuotaRefreshTask {
            return await task.value
        }
        let task = Task { [store] in
            await store.refreshCodexReportingSuccess()
        }
        codexQuotaRefreshTask = task
        let result = await task.value
        if codexQuotaRefreshTask != nil {
            codexQuotaRefreshTask = nil
        }
        return result
    }

    private func refreshLiveQuotaProgressForPopoverOpen() {
        let now = Date()
        let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast)
        let codexElapsed = now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast)
        guard claudeElapsed >= interactiveQuotaRefreshFloorSeconds ||
              codexElapsed >= interactiveQuotaRefreshFloorSeconds else { return }

        Task { [weak self] in
            guard let self else { return }
            _ = await self.refreshLiveQuotaProgressIfDue(force: true)
        }
    }

    private func refreshPayloadForPopoverOpen() {
        // A user viewing the popover is ground truth and must always recover.
        // Unconditionally ensure the loop is alive, then clear the current
        // key's stuck loading / in-flight / generation bookkeeping and force a
        // fresh fetch — even if the cache looks "not stale yet". This is the
        // guaranteed one-round-trip recovery path.
        // An open popover also proves the screens are on: recover from a
        // missed screensDidWake so a latched flag can't suppress refreshes.
        displayAsleep = false
        if refreshTimer == nil {
            startRefreshLoop(forceQuotaOnStart: false)
        }
        // The status figure may have gone stale under App Nap while the
        // popover was closed; catch it up alongside the visible payload.
        refreshStatusPayloadIfNeeded(reason: "popover open")
        if store.shouldResetInteractiveRefreshPipeline,
           let age = store.staleInteractivePayloadAgeSeconds {
            NSLog("CodeBurn: popover opened with %ds stale payload cache - hard recovery", age)
        }
        Task { [weak self] in
            guard let self else { return }
            await self.store.recoverFromStuckLoading()
            self.refreshStatusButton()
        }
    }

    private func stopRefreshTimer() {
        refreshTimer?.setEventHandler {}
        refreshTimer?.cancel()
        refreshTimer = nil
    }

    private func startNapBackstop() {
        let scheduler = NSBackgroundActivityScheduler(
            identifier: "org.agentseal.codeburn-menubar.refresh-backstop")
        scheduler.repeats = true
        scheduler.interval = 180
        scheduler.tolerance = 60
        scheduler.qualityOfService = .utility
        scheduler.schedule { [weak self] completion in
            Task { @MainActor [weak self] in
                self?.runRefreshLoopTick(reason: "nap backstop")
                completion(.finished)
            }
        }
        napBackstop = scheduler
    }

    private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
        refreshLoopHeartbeatAt = Date()
        if displayAsleep && !forcePayload { return }
        let hadForceRefreshInFlight = forceRefreshTask != nil
        let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
        let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
        let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
        let statusPayloadStale = store.needsStatusPayloadRefresh
        let sinceLast = Date().timeIntervalSince(lastRefreshTime)
        // The timer stays at 30s; the spawn interval follows the user's
        // Settings cadence, stretching on battery / Low Power Mode in Auto
        // and never auto-spawning in Manual (#647).
        let interval = currentRefreshInterval()
        let intervalElapsed = interval.map { sinceLast >= $0 } ?? false
        // Stuck-refresh cleanup may retrigger a spawn only when the cadence
        // allows auto-spawns at all: in Manual mode the stale state is
        // cleared and the next user interaction fetches fresh.
        let recoveryRespawnAllowed = interval != nil
        let shouldForceRefresh = forcePayload ||
            ((clearedStaleForceRefresh || clearedStaleLoading) && recoveryRespawnAllowed) ||
            intervalElapsed

        if shouldForceRefresh {
            forceRefresh(bypassRateLimit: true, forceQuota: forceQuota)
        }

        let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil
        if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) {
            guard forcePayload || interval != nil else { return }
            refreshStatusPayloadIfNeeded(reason: reason, force: forcePayload, minAgeSeconds: interval ?? 0)
        }
    }

    private func currentRefreshInterval() -> TimeInterval? {
        RefreshCadence.interval(
            mode: UsageRefreshCadence.current,
            popoverOpen: popover?.isShown ?? false,
            onBattery: PowerSource.isOnBattery(),
            lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled
        )
    }

    private func startRefreshLoop(forceQuotaOnStart: Bool = false) {
        stopRefreshTimer()
        runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart)

        let timer = DispatchSource.makeTimerSource(queue: .main)
        timer.schedule(
            deadline: .now() + .seconds(Int(refreshIntervalSeconds)),
            repeating: .seconds(Int(refreshIntervalSeconds)),
            leeway: .seconds(2)
        )
        timer.setEventHandler { [weak self] in
            Task { @MainActor [weak self] in
                self?.runRefreshLoopTick(reason: "timer")
            }
        }
        refreshTimer = timer
        refreshLoopHeartbeatAt = Date()
        timer.resume()
    }

    @MainActor
    func refreshSubscriptionNow() {
        manualRefreshTask?.cancel()
        manualRefreshGeneration &+= 1
        let generation = manualRefreshGeneration
        forceRefreshTask?.cancel()
        forceRefreshTask = nil
        forceRefreshStartedAt = nil
        forceRefreshGeneration &+= 1
        statusPayloadRefreshTask?.cancel()
        statusPayloadRefreshTask = nil
        statusPayloadRefreshStartedAt = nil
        statusPayloadRefreshGeneration &+= 1
        pendingRefreshWork?.cancel()
        pendingRefreshWork = nil
        stopRefreshTimer()
        store.resetRefreshState(clearCache: true)
        lastRefreshTime = .distantPast
        refreshStatusButton()

        manualRefreshTask = Task { [weak self] in
            guard let self else { return }
            let activity = ProcessInfo.processInfo.beginActivity(
                options: .userInitiated, reason: "CodeBurn manual refresh")
            defer { ProcessInfo.processInfo.endActivity(activity) }
            // "Refresh Now" should refresh the menubar payload AND every
            // connected provider's live quota. The user's intent is "make
            // this match reality right now."
            async let payload: Void = self.refreshUsagePayloads(force: true, showLoading: true)
            async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true)
            _ = await payload
            guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
            self.lastRefreshTime = Date()
            self.refreshStatusButton()
            _ = await quotas
            guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
            self.manualRefreshTask = nil
            if self.refreshTimer == nil {
                self.startRefreshLoop()
            }
        }
    }

    /// Reset the cadence anchor so the next loop tick re-evaluates from "now"
    /// rather than measuring against a timestamp from the previous connection.
    /// Triggered on disconnect of any provider — the cost of clearing both
    /// anchors is one extra refresh tick on the unaffected provider, far less
    /// disruptive than waiting a full cadence after a reconnect.
    @MainActor
    func resetSubscriptionCadenceAnchor() {
        lastSubscriptionRefreshAt = nil
        lastCodexRefreshAt = nil
    }

    private func observeStore() {
        // Read closure uses [weak self] so the implicit self capture from
        // accessing store.* doesn't pin self for the lifetime of an
        // unfired observation. withObservationTracking is one-shot per
        // call: once any read property changes, onChange fires and the
        // registration is consumed, then we re-arm. There is at most one
        // active subscription at a time.
        withObservationTracking { [weak self] in
            guard let self else { return }
            _ = self.store.payload
            _ = self.store.menubarPeriod
            _ = self.store.menubarPayload
            // Track currency so the menubar title catches up immediately on
            // currency switch instead of waiting for the next 30s payload tick.
            _ = self.store.currency
            _ = self.store.displayMetric
            _ = self.store.dailyBudget
            _ = self.store.dailyTokenBudget
            // Read the derived flag so the flame re-tints when today's usage
            // crosses the budget, not only when the budget value itself changes.
            // This also makes the dependency on todayPayload explicit instead of
            // relying on payload/menubarPayload happening to touch the same cache.
            _ = self.store.isOverDailyBudget
            // Track the live-quota state too so the flame icon re-tints on
            // every subscription / codex usage update, not just every 30s.
            _ = self.store.subscription
            _ = self.store.subscriptionLoadState
            _ = self.store.codexUsage
            _ = self.store.codexLoadState
        } onChange: { [weak self] in
            DispatchQueue.main.async {
                guard let self else { return }
                self.pendingRefreshWork?.cancel()
                let work = DispatchWorkItem { [weak self] in
                    self?.refreshStatusButton()
                    self?.observeStore()
                }
                self.pendingRefreshWork = work
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
            }
        }
    }

    // MARK: - Status Item

    private var isCompact: Bool {
        UserDefaults.standard.bool(forKey: "CodeBurnMenubarCompact")
    }

    private func setupStatusItem() {
        statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth)
        guard let button = statusItem.button else { return }

        // Set a simple SF Symbol image immediately to ensure the status item renders.
        // On macOS Tahoe, status items may fail to appear if only an attributed title
        // is set during initial setup.
        let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
        let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
            .withSymbolConfiguration(flameConfig)
        flame?.isTemplate = true
        button.image = flame
        button.imagePosition = .imageLeading

        button.target = self
        button.action = #selector(handleButtonClick(_:))
        // Left-click drives the popover. We keep .rightMouseUp in the mask so the
        // legacy action path below still fires on macOS <= 26; on macOS 27 the
        // system consumes the right button entirely and this never fires, so the
        // global monitor (below) is what restores the right-click menu there.
        button.sendAction(on: [.leftMouseUp, .rightMouseUp])

        // macOS 27 no longer routes any right-mouse event to the status-item
        // button's target/action. A global monitor still observes right-mouse-down;
        // we hit-test it against our own status-item window and present the menu
        // ourselves. Harmless and stable on 15/26 too (the debounce in
        // showContextMenu prevents a double-present if the legacy path also fires).
        rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in
            guard let self,
                  let button = self.statusItem.button,
                  let window = button.window,
                  window.frame.contains(NSEvent.mouseLocation) else { return }
            DispatchQueue.main.async { self.showContextMenu(from: button) }
        }

        // Defer the full attributed title setup to ensure initial render completes
        DispatchQueue.main.async { [weak self] in
            self?.refreshStatusButton()
        }
    }

    /// Composes the menubar title as a single attributed string with the flame as an inline
    /// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a
    /// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge
    /// of the status item, the title starts at its own baseline), so we inline both so they
    /// flow as one typographic unit with a single, controllable gap.
    private static func flameTint(for severity: QuotaSummary.Severity) -> NSColor? {
        switch severity {
        case .normal:   return nil                              // template, auto-adapt
        case .warning:  return NSColor.systemYellow            // 70-90%
        case .critical: return NSColor.systemOrange            // 90-100%
        case .danger:   return NSColor.systemRed               // 100%+
        }
    }

    private func refreshStatusButton() {
        guard let button = statusItem.button else { return }
        // Skip while the popover is anchored to this button. Rewriting the
        // attributedTitle changes the button's intrinsic width, which makes
        // macOS reflow the status item in the menubar and detaches the
        // anchored popover (it pops to a stale default position). The
        // popoverDidClose delegate calls back through here once the popover
        // is dismissed so the menubar cost catches up immediately on close.
        if popover != nil && popover.isShown { return }

        // Clear any previously-set image so the attachment is the only glyph rendered.
        button.image = nil
        button.imagePosition = .noImage

        let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
        let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
        // Tint the flame based on the worst-affected connected provider's quota.
        // Normal (<70%) keeps the template (auto white-on-dark / black-on-light);
        // warning/critical/danger override with a fixed palette color so the
        // user gets a glanceable signal even when the menu bar is busy.
        let aggregate = store.aggregateQuotaStatus
        var tint = Self.flameTint(for: aggregate.severity)
        if tint == nil, store.isOverDailyBudget {
            tint = NSColor.systemYellow
        }
        let flameConfig: NSImage.SymbolConfiguration
        if let tint {
            flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
        } else {
            flameConfig = baseConfig
        }
        let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
            .withSymbolConfiguration(flameConfig)
        flame?.isTemplate = (tint == nil)

        let attachment = NSTextAttachment()
        attachment.image = flame
        if let size = flame?.size {
            attachment.bounds = CGRect(x: 0, y: -3, width: size.width, height: size.height)
        }

        let menubarPeriod = store.menubarPeriod
        let menubarPayload = badgePayload()
        let hasPayload = menubarPayload != nil
        let compact = isCompact

        let composed = NSMutableAttributedString()
        composed.append(NSAttributedString(attachment: attachment))

        if store.displayMetric != .iconOnly {
            let suffix = menubarPeriod.menubarSuffix(compact: compact)
            let valueText: String
            if store.displayMetric == .tokens, let p = menubarPayload?.current {
                let out = formatTokensMenubar(Double(p.outputTokens))
                let inp = formatTokensMenubar(Double(p.inputTokens))
                valueText = compact ? "↑\(out)↓\(inp)\(suffix)" : " ↑\(out) ↓\(inp)\(suffix)"
            } else if store.displayMetric == .totalTokens, let p = menubarPayload?.current {
                let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens))
                valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)"
            } else if store.displayMetric == .credits, let p = menubarPayload?.current {
                let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded())
                valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)"
            } else {
                let fallback = compact ? "$-" : "$—"
                let formatted = menubarPayload?.current.cost
                valueText = compact
                    ? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix
                    : " " + (formatted?.asCompactCurrency() ?? fallback) + suffix
            }

            var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
            if !hasPayload {
                textAttrs[.foregroundColor] = NSColor.secondaryLabelColor
            }
            composed.append(NSAttributedString(string: valueText, attributes: textAttrs))
        }

        button.attributedTitle = composed
        button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"

        persistBadgeStatusFile()
    }

    private var lastWrittenBadgeGenerated: String?

    // Mirror the freshest in-memory payload to disk so the badge survives an app
    // restart. Skips redundant writes by tracking the last payload's `generated`
    // stamp. This is the only writer now that the launchd fetcher is gone.
    private func persistBadgeStatusFile() {
        // Only persist the unscoped (All) badge. Config selection resets to All
        // on relaunch, so a scoped payload on disk would show the wrong config's
        // number at next launch.
        guard store.selectedClaudeConfigSourceId == nil else { return }
        guard let payload = store.menubarPayload else { return }
        guard payload.generated != lastWrittenBadgeGenerated else { return }
        do {
            try MenubarStatusCache.standard().writeStatus(payload)
            lastWrittenBadgeGenerated = payload.generated
        } catch {
            NSLog("CodeBurn: failed to write badge status file: \(error)")
        }
    }

    // Badge falls back to the on-disk status file (written by a prior app run)
    // when the in-app loop has no payload yet; in-memory wins when it's fresher.
    // The 10-min bound discards a file too stale to trust.
    private func badgePayload() -> MenubarPayload? {
        let inMemory = store.menubarPayload
        // While scoped to a config, never fall back to the on-disk badge: it
        // holds the unscoped All payload and would show the wrong number.
        if store.selectedClaudeConfigSourceId != nil { return inMemory }
        let inMemoryAge = store.menubarPayloadAgeSeconds.map(TimeInterval.init)
        guard let fileRead = MenubarStatusCache.standard().readBadgePayload(maxAgeSeconds: 600) else {
            return inMemory
        }
        if inMemory == nil { return fileRead.payload }
        if let age = inMemoryAge, fileRead.ageSeconds < age { return fileRead.payload }
        return inMemory
    }

    private func formatTokensMenubar(_ n: Double) -> String {
        if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
        if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
        if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
        return String(format: "%.0f", n)
    }

    // MARK: - Popover

    private func setupPopover() {
        popover = NSPopover()
        popover.contentSize = NSSize(width: popoverWidth, height: popoverHeight)
        popover.behavior = .transient  // auto-close only on explicit outside click
        popover.animates = true
        popover.delegate = self

        let content = MenuBarContent()
            .environment(store)
            .environment(updateChecker)
            .frame(width: popoverWidth)

        popover.contentViewController = NSHostingController(rootView: content)
    }

    @objc private func handleButtonClick(_ sender: AnyObject?) {
        guard let button = statusItem.button,
              let event = NSApp.currentEvent else { return }

        // Legacy right-click path for macOS <= 26 (no-op on 27, where the action
        // never receives a right-mouse event — the global monitor handles it).
        if event.type == .rightMouseUp {
            showContextMenu(from: button)
            return
        }

        if popover.isShown {
            popover.performClose(sender)
        } else {
            // Do NOT call NSApp.activate(ignoringOtherApps:) here. On macOS
            // Tahoe an accessory app activating while a popover anchors to
            // its NSStatusItem can race with the system menu bar's auto-hide
            // logic and leave the user's apple-menu hidden until the popover
            // closes. The popover's window takes keyboard focus on its own
            // via makeKeyAndOrderFront, which is enough for keystrokes to
            // reach the SwiftUI content.
            popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
            if let window = popover.contentViewController?.view.window {
                // Pin the popover's window above the status-bar layer but tag
                // it as auxiliary so macOS Tahoe does not treat it as an
                // app-level focus event — that's what was hiding the system
                // menu bar (Terminal's apple-logo / Shell / Edit / View row)
                // every time the popover opened.
                window.level = .statusBar
                window.collectionBehavior.insert(.fullScreenAuxiliary)
                window.collectionBehavior.insert(.canJoinAllSpaces)
                window.makeKeyAndOrderFront(nil)
            }
            refreshPayloadForPopoverOpen()
            refreshLiveQuotaProgressForPopoverOpen()
        }
    }

    private func showContextMenu(from button: NSStatusBarButton) {
        // Debounce: on macOS <= 26 both the legacy action path and the global
        // monitor can fire for a single right-click. Present at most once per click.
        let now = Date()
        guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return }
        lastContextMenuPresentedAt = now

        let menu = NSMenu()

        // Glanceable "today" usage at the top as a single non-interactive (dimmed)
        // row. A real .sectionHeader adds section padding (and pulls the actions
        // into its group without a separator), so use a plain disabled item.
        let usageItem = NSMenuItem(title: contextMenuUsageSummary(), action: nil, keyEquivalent: "")
        usageItem.isEnabled = false
        menu.addItem(usageItem)

        let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: "")
        settingsItem.target = self
        settingsItem.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
        menu.addItem(settingsItem)

        let refreshNow = NSMenuItem(title: "Refresh Now", action: #selector(refreshNowAction), keyEquivalent: "")
        refreshNow.target = self
        menu.addItem(refreshNow)

        let updateItem = NSMenuItem(title: "Check for Updates", action: #selector(checkForUpdates), keyEquivalent: "")
        updateItem.target = self
        menu.addItem(updateItem)

        let aboutItem = NSMenuItem(title: "About CodeBurn", action: #selector(openAbout), keyEquivalent: "")
        aboutItem.target = self
        menu.addItem(aboutItem)

        let quitItem = NSMenuItem(title: "Quit CodeBurn", action: #selector(quitApp), keyEquivalent: "")
        quitItem.target = self
        menu.addItem(quitItem)

        // Present directly. The previous `statusItem.menu = menu; button.performClick`
        // trick relies on the click -> action path that macOS 27 changed; popUp is
        // version-stable. Open a few px below the status item so the menu clears the
        // menu bar: anchoring flush clips the top edge and makes macOS engage menu
        // scrolling (a scroll chevron appears and the first row slides up on hover).
        menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button)
    }

    /// One-line "today" summary for the context menu's usage row.
    private func contextMenuUsageSummary() -> String {
        guard let current = store.todayPayload?.current else { return "Today · no usage yet" }
        let calls = current.calls == 1 ? "1 call" : "\(current.calls) calls"
        return "Today · \(current.cost.asCurrency()) · \(calls)"
    }

    private var settingsWindowController: NSWindowController?

    @objc private func openAbout() {
        store.settingsTab = "about"
        openSettings()
    }

    @objc private func openSettings() {
        // Accessory-policy apps (no Dock icon, no main menu) don't get the
        // SwiftUI Settings scene wired into the responder chain reliably, so
        // the standard `showSettingsWindow:` selector silently no-ops. We host
        // the SwiftUI view in our own NSWindowController instead.
        if let controller = settingsWindowController {
            NSApp.activate(ignoringOtherApps: true)
            controller.window?.makeKeyAndOrderFront(nil)
            return
        }

        let hosting = NSHostingController(
            rootView: SettingsView().environment(store)
        )
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 520, height: 380),
            styleMask: [.titled, .closable, .miniaturizable],
            backing: .buffered,
            defer: false
        )
        window.title = "CodeBurn Settings"
        window.contentViewController = hosting
        window.center()
        window.isReleasedWhenClosed = false
        let controller = NSWindowController(window: window)
        settingsWindowController = controller
        NSApp.activate(ignoringOtherApps: true)
        controller.showWindow(nil)
    }

    @objc private func refreshNowAction() {
        refreshSubscriptionNow()
    }

    private func codeburnAlertIcon() -> NSImage? {
        let config = NSImage.SymbolConfiguration(pointSize: 32, weight: .medium)
        guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
            .withSymbolConfiguration(config) else { return nil }
        let size = NSSize(width: 64, height: 64)
        let img = NSImage(size: size, flipped: false) { rect in
            let symbolSize = symbol.size
            let x = (rect.width - symbolSize.width) / 2
            let y = (rect.height - symbolSize.height) / 2
            symbol.draw(in: NSRect(x: x, y: y, width: symbolSize.width, height: symbolSize.height))
            return true
        }
        img.isTemplate = false
        return img
    }

    @objc private func checkForUpdates() {
        Task {
            await updateChecker.check()
            let alert = NSAlert()
            alert.icon = codeburnAlertIcon()
            if let error = updateChecker.updateError {
                alert.messageText = "Update Check Failed"
                alert.informativeText = error
                alert.alertStyle = .warning
            } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
                alert.messageText = "Update Available"
                let header = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion)))."
                if updateChecker.cliTooOldForUpdate {
                    alert.informativeText = "\(header) Your codeburn CLI is too old to install it. First run:\n\n\(updateChecker.cliUpdateCommand)\n\nthen:\n\ncodeburn menubar --force"
                } else {
                    alert.informativeText = "\(header) Run:\n\ncodeburn menubar --force"
                }
                alert.alertStyle = .informational
            } else {
                alert.messageText = "Up to Date"
                alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
                alert.alertStyle = .informational
            }
            alert.addButton(withTitle: "OK")
            alert.runModal()
        }
    }

    @objc private func quitApp() {
        NSApp.terminate(nil)
    }

    // MARK: - NSPopoverDelegate

    func popoverShouldDetach(_ popover: NSPopover) -> Bool {
        false
    }

    func popoverDidClose(_ notification: Notification) {
        // Catch up on any menubar title updates that were skipped while the
        // popover was anchored.
        refreshStatusButton()
    }
}
