// AgentStateHolder.swift — Main @Observable state store
// Ported from android AgentState.kt (AgentStateHolder)

import Foundation
import Combine
#if os(macOS)
import AppKit
import IOKit
import IOKit.ps

// IOKit power-management constant (the real symbol is private in the Swift import).
private let kIOMessageSystemHasPoweredOn_AgentStateHolder: UInt32 = 0xe0000300
#endif

final class AgentStateHolder: ObservableObject, @unchecked Sendable {
    // MARK: - State

    @Published private(set) var state = DashboardState()
    @Published private(set) var lastDataReceivedAt: Date?
    private var lastKnownState: DashboardState?

    // MARK: - Dependencies

    let connection = BridgeConnection()
    let discovery = BridgeDiscovery()
    let timelineStore = TimelineStore()
    let displaySync = DisplaySyncService()
    private(set) var timelineGenerator: StateTimelineGenerator!

    /// Bump to trigger SwiftUI re-render for nested timelineStore changes
    @Published private(set) var timelineVersion: Int = 0

    // MARK: - URL Persistence

    private static let lastBridgeUrlKey = "lastBridgeUrl"

    private var savedUrl: String? {
        get { UserDefaults.standard.string(forKey: Self.lastBridgeUrlKey) }
        set {
            if let newValue {
                UserDefaults.standard.set(newValue, forKey: Self.lastBridgeUrlKey)
            } else {
                UserDefaults.standard.removeObject(forKey: Self.lastBridgeUrlKey)
            }
        }
    }

    // MARK: - Lifecycle

    private var backgroundEnteredAt: Date?
    private var isTerminating = false
    #if os(macOS)
    private var staleDataMonitor: Timer?
    private static let staleDataThresholdSec: TimeInterval = 20
    /// Ignore stale watchdog for this long after a wake so in-process daemon
    /// recovery (ESP32 2s serial reopen, mDNS/Bonjour republish) can complete
    /// before we judge the socket dead. D200H HID recovery used to need a 10s
    /// window because its 5s IOHIDManager restart blocked the main thread;
    /// now that HID callbacks run on `HIDRunLoopThread` (see D200hHidModule),
    /// 3s is enough for the lighter remaining work.
    private static let wakeGracePeriodSec: TimeInterval = 3
    /// Debounce threshold for handleSystemWake — IOKit SystemHasPoweredOn +
    /// NSWorkspace.screensDidWake both fire on S3→wake.
    private static let wakeDebounceSec: TimeInterval = 1
    private var wakeNotificationPort: IONotificationPortRef?
    private var wakeNotifier: io_object_t = 0
    private var wakeRootDomain: io_object_t = 0
    private var displayWakeObserver: NSObjectProtocol?
    private var lastWakeHandledAt: Date?
    #endif

    // MARK: - Connection Waterfall State

    @Published private(set) var isAutoConnecting = false
    private var waterfallStage: WaterfallStage = .idle
    private var preferredLocalBridgeUrl: String?

    /// Bridges that failed to connect — skip them until browseResults refresh
    private var failedBridgeIds: Set<String> = []
    /// Track last browseResults count to detect mDNS refresh and clear blacklist
    private var lastBrowseCount: Int = 0
    private var cancellables = Set<AnyCancellable>()

    private enum WaterfallStage {
        case idle
        case savedUrl        // trying last known URL
        case mdns            // mDNS discovery
    }

    // MARK: - Init

    init() {
        #if DEBUG && os(iOS)
        // App Store screenshot capture only: pin a freshly installed Simulator
        // directly to the deterministic local mock before mDNS can discover a
        // developer daemon. Release builds do not compile this path.
        let arguments = ProcessInfo.processInfo.arguments
        if let index = arguments.firstIndex(of: "-AgentDeckScreenshotURL"),
           arguments.indices.contains(index + 1) {
            preferredLocalBridgeUrl = arguments[index + 1]
        }
        #endif

        connection.objectWillChange
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.objectWillChange.send() }
            .store(in: &cancellables)
        discovery.objectWillChange
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.objectWillChange.send() }
            .store(in: &cancellables)
        timelineStore.objectWillChange
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.objectWillChange.send() }
            .store(in: &cancellables)
        displaySync.objectWillChange
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.objectWillChange.send() }
            .store(in: &cancellables)

        // iOS: react to mDNS bridge changes even after autoConnect timer expires.
        // Without this, if daemon restarts after the 10s polling window, iOS never reconnects.
        discovery.$bridges
            .receive(on: DispatchQueue.main)
            .sink { [weak self] bridges in
                guard let self else { return }
                guard !self.isTerminating else { return }
                guard self.preferredLocalBridgeUrl == nil else { return }
                guard !self.state.bridgeConnected,
                      self.connection.status == .disconnected else { return }
                guard self.autoConnectTimer == nil else { return }

                let candidates = bridges.filter { !self.failedBridgeIds.contains($0.id) }
                let bridge = candidates.first(where: { $0.agentType == "daemon" })
                    ?? candidates.first(where: { $0.agentType != nil })
                if let bridge {
                    print("[AutoReconnect] new bridge appeared while disconnected: \(bridge.wsUrl)")
                    self.failedBridgeIds.removeAll()
                    self.connectTo(bridge)
                }
            }
            .store(in: &cancellables)

        timelineGenerator = StateTimelineGenerator(store: timelineStore)
        connection.onEvent = { [weak self] event in
            guard let self, !self.isTerminating else { return }
            self.handleEvent(event)
        }
        connection.onDisconnect = { [weak self] in
            guard let self else { return }
            guard !self.isTerminating else { return }
            if self.state.bridgeConnected {
                self.resetToDisconnected()
            }
            // macOS local daemon mode should reconnect directly instead of discovering itself via mDNS
            if let preferredLocalBridgeUrl = self.preferredLocalBridgeUrl {
                self.connectTo(url: preferredLocalBridgeUrl)
            } else {
                // Start mDNS discovery during reconnect so we can find new bridges
                self.discovery.startSearching()
            }
        }
        connection.onReconnectExhausted = { [weak self] in
            guard let self else { return }
            guard !self.isTerminating else { return }
            // Blacklist the failed bridge so we skip it in auto-connect
            if let url = self.connection.url,
               let bridge = self.discovery.bridges.first(where: { $0.wsUrl == url }) {
                self.failedBridgeIds.insert(bridge.id)
                print("[Waterfall] blacklisted bridge \(bridge.id) after reconnect exhausted")
            }
            self.savedUrl = nil
            self.waterfallStage = .idle
            self.startConnectionWaterfall()
        }

        // On each reconnect attempt, check for available bridges.
        // If found, abort stale-URL reconnect and connect to the new bridge.
        connection.onReconnectAttempt = { [weak self] in
            guard let self else { return false }
            guard !self.isTerminating else { return true }

            // Check mDNS discovered bridges (skip blacklisted, prefer daemon)
            let candidates = self.discovery.bridges.filter { !self.failedBridgeIds.contains($0.id) }
            let bridge = candidates.first(where: { $0.agentType == "daemon" })
                ?? candidates.first
            if let bridge, bridge.wsUrl != self.connection.url {
                DispatchQueue.main.async {
                    self.savedUrl = nil
                    self.waterfallStage = .idle
                    self.connectTo(bridge)
                }
                return true  // abort reconnect
            }

            return false
        }

        #if os(macOS)
        startStaleDataMonitor()
        startSystemWakeListener()
        #endif
    }

    #if os(macOS)
    deinit {
        stopSystemWakeListener()
    }
    #endif

    #if os(macOS)
    private func startSystemWakeListener() {
        guard wakeNotificationPort == nil else { return }
        guard let port = IONotificationPortCreate(kIOMainPortDefault) else {
            print("[Lifecycle] failed to create IONotificationPort for wake listener")
            return
        }
        IONotificationPortSetDispatchQueue(port, DispatchQueue.main)
        let rootDomain = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPMrootDomain"))
        guard rootDomain != 0 else {
            print("[Lifecycle] failed to match IOPMrootDomain")
            IONotificationPortDestroy(port)
            return
        }
        var notifier: io_object_t = 0
        let callback: IOServiceInterestCallback = { (refcon, _, messageType, _) in
            guard messageType == kIOMessageSystemHasPoweredOn_AgentStateHolder else { return }
            guard let refcon else { return }
            let holder = Unmanaged<AgentStateHolder>.fromOpaque(refcon).takeUnretainedValue()
            holder.handleSystemWake()
        }
        let result = IOServiceAddInterestNotification(
            port,
            rootDomain,
            kIOGeneralInterest,
            callback,
            Unmanaged.passUnretained(self).toOpaque(),
            &notifier
        )
        if result != KERN_SUCCESS {
            print("[Lifecycle] IOServiceAddInterestNotification failed: \(result)")
            IOObjectRelease(rootDomain)
            IONotificationPortDestroy(port)
            return
        }
        self.wakeNotificationPort = port
        self.wakeNotifier = notifier
        self.wakeRootDomain = rootDomain

        // Display wake (monitor on) — separate from system wake
        displayWakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
            forName: NSWorkspace.screensDidWakeNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.handleSystemWake()
        }

        print("[Lifecycle] system + display wake listener installed")
    }

    private func stopSystemWakeListener() {
        if let obs = displayWakeObserver {
            NSWorkspace.shared.notificationCenter.removeObserver(obs)
            displayWakeObserver = nil
        }
        if wakeNotifier != 0 {
            IOObjectRelease(wakeNotifier)
            wakeNotifier = 0
        }
        if wakeRootDomain != 0 {
            IOObjectRelease(wakeRootDomain)
            wakeRootDomain = 0
        }
        if let port = wakeNotificationPort {
            IONotificationPortDestroy(port)
            wakeNotificationPort = nil
        }
    }

    private func handleSystemWake() {
        guard !isTerminating else { return }
        let now = Date()
        if let last = lastWakeHandledAt, now.timeIntervalSince(last) < Self.wakeDebounceSec {
            return
        }
        lastWakeHandledAt = now
        // Give the stale-data watchdog a fresh reference point so it doesn't
        // fire mid-reconnect using the pre-sleep timestamp.
        lastDataReceivedAt = now
        print("[Lifecycle] system wake — force reconnect")
        connection.forceDisconnectAndRestart()
        if let preferredLocalBridgeUrl {
            connectTo(url: preferredLocalBridgeUrl)
        } else {
            restartWaterfall()
        }
    }

    private func startStaleDataMonitor() {
        staleDataMonitor?.invalidate()
        staleDataMonitor = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
            self?.checkForStaleBridgeData()
        }
        if let staleDataMonitor {
            RunLoop.main.add(staleDataMonitor, forMode: .common)
        }
    }

    private func checkForStaleBridgeData() {
        guard !isTerminating else { return }
        guard state.bridgeConnected,
              connection.status == .connected,
              !connection.isReconnecting,
              let preferredLocalBridgeUrl,
              let lastDataReceivedAt else { return }

        if let wakeAt = lastWakeHandledAt,
           Date().timeIntervalSince(wakeAt) < Self.wakeGracePeriodSec {
            return
        }

        let age = Date().timeIntervalSince(lastDataReceivedAt)
        guard age > Self.staleDataThresholdSec else { return }

        print("[Lifecycle] bridge data stale (\(Int(age))s) — reconnecting preferred local bridge")
        connection.forceDisconnectAndRestart()
        connectTo(url: preferredLocalBridgeUrl)
    }
    #endif

    // MARK: - Lifecycle Handlers

    func prepareForTermination() {
        guard !isTerminating else { return }
        isTerminating = true

        backgroundEnteredAt = nil
        preferredLocalBridgeUrl = nil
        isAutoConnecting = false
        waterfallStage = .idle
        autoConnectTimer?.invalidate()
        autoConnectTimer = nil

        #if os(macOS)
        staleDataMonitor?.invalidate()
        staleDataMonitor = nil
        stopSystemWakeListener()
        #endif

        discovery.prepareForTermination()
        connection.prepareForTermination()
    }

    func handleBackgroundEntry() {
        guard !isTerminating else { return }
        backgroundEnteredAt = Date()
        connection.stopPingTimer()
        print("[Lifecycle] entered background")
    }

    func handleForegroundReturn() {
        guard !isTerminating else { return }
        let suspendDuration: TimeInterval
        if let enteredAt = backgroundEnteredAt {
            suspendDuration = Date().timeIntervalSince(enteredAt)
        } else {
            suspendDuration = 0
        }
        backgroundEnteredAt = nil
        print("[Lifecycle] foreground return, suspend=\(String(format: "%.1f", suspendDuration))s, connected=\(state.bridgeConnected)")

        // Sync display brightness on foreground return
        #if os(iOS)
        displaySync.handleForegroundReturn(hostDisplayOn: state.hostDisplayOn)
        #endif

        #if os(macOS)
        if !state.bridgeConnected {
            if let preferredLocalBridgeUrl {
                print("[Lifecycle] macOS forcing reconnect directly to preferred local bridge")
                connection.forceDisconnectAndRestart()
                connectTo(url: preferredLocalBridgeUrl)
            } else {
                print("[Lifecycle] macOS restarting waterfall on foreground return")
                restartWaterfall()
            }
            return
        }

        if suspendDuration > 15 {
            print("[Lifecycle] long suspend (\(Int(suspendDuration))s) — socket dead, reconnect preferred local bridge")
            connection.forceDisconnectAndRestart()
            if let preferredLocalBridgeUrl {
                connectTo(url: preferredLocalBridgeUrl)
            }
        } else if suspendDuration > 5 {
            print("[Lifecycle] medium suspend (\(Int(suspendDuration))s) — health check")
            connection.forceHealthCheck { [weak self] alive in
                guard let self else { return }
                guard !self.isTerminating else { return }
                if !alive {
                    print("[Lifecycle] health check failed — reconnecting preferred local bridge")
                    self.connection.forceDisconnectAndRestart()
                    if let preferredLocalBridgeUrl = self.preferredLocalBridgeUrl {
                        self.connectTo(url: preferredLocalBridgeUrl)
                    }
                } else {
                    print("[Lifecycle] health check passed")
                    self.connection.startPingTimer()
                }
            }
            return
        } else {
            connection.startPingTimer()
        }
#else
        if !state.bridgeConnected {
            // Not connected — restart discovery
            restartWaterfall()
        } else if suspendDuration > 15 {
            // Server terminates after ~15s without pong — socket is dead
            print("[Lifecycle] long suspend (\(Int(suspendDuration))s) — socket dead, force reconnect")
            connection.forceDisconnectAndRestart()
            restartWaterfall()
        } else if suspendDuration > 5 {
            // 5-15s: might still be alive — health check
            print("[Lifecycle] medium suspend (\(Int(suspendDuration))s) — health check")
            connection.forceHealthCheck { [weak self] alive in
                guard let self else { return }
                guard !self.isTerminating else { return }
                if !alive {
                    print("[Lifecycle] health check failed — reconnecting")
                    self.connection.forceDisconnectAndRestart()
                    self.restartWaterfall()
                } else {
                    print("[Lifecycle] health check passed")
                    self.connection.startPingTimer()
                }
            }
            return  // Don't restart ping timer yet — health check callback will
        } else {
            // Short suspend — just restart ping timer
            connection.startPingTimer()
        }
#endif
    }

    private func restartWaterfall() {
        guard !isTerminating else { return }
        // Force reset waterfall stage so startConnectionWaterfall() can enter
        waterfallStage = .idle
        connection.resetReconnectCount()
        startConnectionWaterfall()
    }

    // MARK: - Connection Waterfall

    func startConnectionWaterfall() {
        guard !isTerminating else { return }
        if let preferredLocalBridgeUrl {
            isAutoConnecting = false
            waterfallStage = .idle
            if connection.url != preferredLocalBridgeUrl || connection.status == .disconnected {
                connectTo(url: preferredLocalBridgeUrl)
            }
            return
        }
        guard waterfallStage == .idle else {
            print("[Waterfall] already in stage \(waterfallStage), skipping")
            return
        }
        isAutoConnecting = true
        print("[Waterfall] starting waterfall")

        // Always mDNS first — savedUrl can be stale after DHCP/network changes.
        // savedUrl is tried as fallback after 4s if no mDNS results.
        startMdnsDiscovery()
    }

    private func trySavedUrl() {
        guard !isTerminating else { return }
        if let url = savedUrl {
            print("[Waterfall] trying saved URL: \(url)")
            waterfallStage = .savedUrl
            connectTo(url: url)

            // Timeout: if not connected within 5 seconds, fall through to mDNS
            // (iOS WiFi init can take 2-4s after app launch)
            DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
                guard let self, self.waterfallStage == .savedUrl else { return }
                guard !self.isTerminating else { return }
                if !self.state.bridgeConnected {
                    print("[Waterfall] saved URL timeout, falling through to mDNS")
                    self.connection.disconnect(reconnect: false)
                    self.startMdnsDiscovery()
                }
            }
        } else {
            print("[Waterfall] no saved URL, going to mDNS")
            startMdnsDiscovery()
        }
    }

    private func startMdnsDiscovery() {
        guard !isTerminating else { return }
        print("[Waterfall] starting mDNS discovery")
        waterfallStage = .mdns
        discovery.startSearching()

        // Poll for discovered bridges and auto-connect to the first one
        startAutoConnectPolling()
    }

    private var autoConnectTimer: Timer?
    private var autoConnectPollCount = 0

    private func startAutoConnectPolling() {
        guard !isTerminating else { return }
        autoConnectPollCount = 0
        autoConnectTimer?.invalidate()
        autoConnectTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
            guard let self else { timer.invalidate(); return }
            guard !self.isTerminating else {
                timer.invalidate()
                self.autoConnectTimer = nil
                return
            }
            guard self.waterfallStage == .mdns else {
                print("[AutoConnect] timer stopped: stage=\(self.waterfallStage)")
                timer.invalidate()
                self.autoConnectTimer = nil
                return
            }
            guard !self.state.bridgeConnected else {
                print("[AutoConnect] timer stopped: already connected")
                timer.invalidate()
                self.autoConnectTimer = nil
                return
            }

            // Skip if already trying to connect
            if self.connection.status != .disconnected {
                print("[AutoConnect] skipping: connection status=\(self.connection.status)")
                return
            }

            // Clear blacklist when browseResults change (stale mDNS entries removed)
            let currentBrowseCount = self.discovery.bridges.count
            if currentBrowseCount != self.lastBrowseCount {
                if !self.failedBridgeIds.isEmpty {
                    print("[AutoConnect] browseResults changed (\(self.lastBrowseCount)→\(currentBrowseCount)), clearing \(self.failedBridgeIds.count) failed bridges")
                    self.failedBridgeIds.removeAll()
                }
                self.lastBrowseCount = currentBrowseCount
            }

            print("[AutoConnect] poll: bridges=\(self.discovery.bridges.count), failed=\(self.failedBridgeIds.count), searching=\(self.discovery.isSearching)")

            // After 4s with no mDNS results, try savedUrl as fallback
            if self.autoConnectPollCount == 8, self.discovery.bridges.isEmpty, let url = self.savedUrl {
                print("[AutoConnect] no mDNS after 4s, trying saved URL: \(url)")
                timer.invalidate()
                self.autoConnectTimer = nil
                self.waterfallStage = .savedUrl
                self.connectTo(url: url)
                return
            }

            // Filter out bridges that previously failed to connect (ghost mDNS entries)
            let candidates = self.discovery.bridges.filter { !self.failedBridgeIds.contains($0.id) }

            // Prefer daemon bridge for consistent state (daemon aggregates all sessions)
            let daemon = candidates.first(where: { $0.agentType == "daemon" })
            if let daemon {
                print("[AutoConnect] connecting to daemon: \(daemon.wsUrl)")
                timer.invalidate()
                self.autoConnectTimer = nil
                self.connectTo(daemon)
            } else if !candidates.isEmpty {
                // If some bridges have nil agentType (health not yet resolved), wait up to 4s
                // for /health responses before falling back to any bridge
                let hasUnresolved = candidates.contains(where: { $0.agentType == nil })
                if hasUnresolved && self.autoConnectPollCount < 8 {
                    print("[AutoConnect] waiting for health info (\(candidates.count) bridges, some unresolved)")
                } else {
                    guard let bridge = candidates.first else { return }
                    print("[AutoConnect] connecting to bridge: \(bridge.wsUrl) (agent=\(bridge.agentType ?? "?"))")
                    timer.invalidate()
                    self.autoConnectTimer = nil
                    self.connectTo(bridge)
                }
            }

            // After 10 seconds with no mDNS results, stop polling
            // (user can still manually enter URL via ConnectionOverlay)
            self.autoConnectPollCount += 1
            if self.autoConnectPollCount >= 20 {  // 20 × 0.5s = 10s
                print("[AutoConnect] giving up after 10s with no bridges found")
                timer.invalidate()
                self.autoConnectTimer = nil
                self.isAutoConnecting = false
                self.waterfallStage = .idle
            }
        }
    }

    // MARK: - Event Handler

    func handleEvent(_ event: BridgeEvent) {
        guard !isTerminating else { return }
        switch event {
        case .stateUpdate(let e):
            handleStateUpdate(e)
        case .usageUpdate(let e):
            handleUsageUpdate(e)
        case .connection(let e):
            handleConnection(e)
        case .voiceState(let e):
            state.voiceState = e.state
            state.voiceText = e.text
            state.voiceError = e.error
        case .displayState(let e):
            state.hostDisplayOn = e.displayOn
            displaySync.handleDisplayState(displayOn: e.displayOn, dim: e.dim)
        case .sessionsList(let e):
            state.siblingSessions = e.sessions
            #if os(macOS)
            // Post/clear the "needs your response" system notification for
            // sessions entering/leaving an awaiting state. App-layer so it
            // covers both tiers (in-process Swift daemon + external Node
            // daemon client mode) with one implementation.
            let sessions = e.sessions
            Task { @MainActor in AttentionNotifier.sync(sessions: sessions) }
            #endif
        case .promptOptions(let e):
            state.options = e.options
            state.promptType = PromptType(rawValue: e.promptType)
            state.question = e.question
        case .buttonState:
            break  // Deck UI removed
        case .encoderState:
            break  // Deck UI removed
        case .deckSlotMap:
            break  // Deck UI removed
        case .userPrompt:
            break  // handled by voice/deck UI
        case .timelineEvent(let e):
            timelineGenerator.receivingBridgeTimeline = true
            timelineStore.addEntry(e.entry, upsert: e.upsert ?? false)
            timelineVersion += 1
        case .timelineHistory(let e):
            timelineGenerator.receivingBridgeTimeline = true
            timelineStore.mergeHistory(e.entries)
            timelineVersion += 1
        }

        // Track last data received time for stale indicator
        switch event {
        case .stateUpdate, .usageUpdate, .sessionsList, .timelineEvent, .timelineHistory:
            lastDataReceivedAt = Date()
        default:
            break
        }

        // Cache state for offline display
        if case .stateUpdate = event { lastKnownState = state }
        if case .usageUpdate = event { lastKnownState = state }
    }

    // MARK: - State Update

    private func handleStateUpdate(_ e: StateUpdateEvent) {
        // Build the update on a local copy and assign once at the end: `state`
        // is @Published, so field-by-field writes fired ~40 objectWillChange
        // publishes (each a full SwiftUI invalidation) per state_update.
        var s = state
        // Null-coalescing: only update fields that are present
        s.state = AgentConnectionState(rawValue: e.state) ?? s.state
        if let pm = e.permissionMode { s.permissionMode = PermissionMode(rawValue: pm) ?? s.permissionMode }
        s.agentType = e.agentType ?? s.agentType
        if let sid = e.sessionId { s.sessionId = sid }
        if let focusedSessionId = e.focusedSessionId {
            s.focusedSessionId = focusedSessionId.isEmpty ? nil : focusedSessionId
        }
        s.agentCapabilities = e.agentCapabilities ?? s.agentCapabilities
        s.currentTool = e.currentTool ?? s.currentTool
        s.toolInput = e.toolInput ?? s.toolInput
        s.toolProgress = e.toolProgress ?? s.toolProgress
        s.projectName = e.projectName ?? s.projectName
        s.modelName = e.modelName ?? s.modelName
        s.effortLevel = e.effortLevel ?? s.effortLevel
        if let bt = e.billingType { s.billingType = BillingType(rawValue: bt) ?? s.billingType }
        if let opts = e.options { s.options = opts }
        if let pt = e.promptType { s.promptType = PromptType(rawValue: pt) }
        s.question = e.question ?? s.question
        s.navigable = e.navigable ?? s.navigable
        s.cursorIndex = e.cursorIndex ?? s.cursorIndex
        s.suggestedPrompt = e.suggestedPrompt ?? s.suggestedPrompt
        if let mc = e.modelCatalog { s.modelCatalog = mc }
        s.sessionStatus = e.sessionStatus ?? s.sessionStatus
        s.remoteUrl = e.remoteUrl ?? s.remoteUrl
        s.pairingUrl = e.pairingUrl ?? s.pairingUrl
        s.workerSessionCount = e.workerSessionCount ?? s.workerSessionCount
        if let os = e.ollamaStatus { s.ollamaStatus = os }
        s.mlxModels = e.mlxModels ?? s.mlxModels
        if let subscriptions = e.subscriptions {
            s.subscriptions = subscriptions
        }
        let sawCodexAuthField = e.codexAuthMode != nil
            || e.codexWebAuthConnected != nil
            || e.codexPlanType != nil
            || e.codexAccountId != nil
            || e.codexSubscriptionActiveUntil != nil
            || e.codexLastRefreshAt != nil
        s.codexAuthMode = e.codexAuthMode ?? s.codexAuthMode
        s.codexWebAuthConnected = e.codexWebAuthConnected ?? s.codexWebAuthConnected
        s.codexPlanType = e.codexPlanType ?? s.codexPlanType
        s.codexAccountId = e.codexAccountId ?? s.codexAccountId
        s.codexSubscriptionActiveUntil = e.codexSubscriptionActiveUntil ?? s.codexSubscriptionActiveUntil
        s.codexLastRefreshAt = e.codexLastRefreshAt ?? s.codexLastRefreshAt
        if e.subscriptions == nil {
            reconcileCodexSubscriptionFallback(clearWhenUnavailable: sawCodexAuthField, state: &s)
        }
        s.antigravityStatus = e.antigravityStatus ?? s.antigravityStatus
        s.gatewayAvailable = e.gatewayAvailable ?? s.gatewayAvailable
        s.gatewayConnected = e.gatewayConnected ?? s.gatewayConnected
        s.gatewayHasError = e.gatewayHasError ?? s.gatewayHasError
        if let gatewayAuthStatus = e.gatewayAuthStatus {
            s.gatewayAuthStatus = gatewayAuthStatus
            s.gatewayAuthRequestId = e.gatewayAuthRequestId
            s.gatewayAuthMessage = e.gatewayAuthMessage
        }
        s.gatewayDeviceId = e.gatewayDeviceId ?? s.gatewayDeviceId
        s.daemonPort = e.daemonPort ?? s.daemonPort
        s.mlxModelCatalog = e.mlxModelCatalog ?? s.mlxModelCatalog
        s.voiceAssistantState = e.voiceAssistantState ?? s.voiceAssistantState
        s.voiceAssistantText = e.voiceAssistantText  // null when idle, no fallback
        s.voiceAssistantResponseText = e.voiceAssistantResponseText  // null when idle
        if let mh = e.moduleHealth { s.moduleHealth = mh }

        // OpenClaw Gateway provides its own rich timeline entries via timeline_event.
        // Suppress the StateTimelineGenerator fallback ("Prompt sent" etc.) as soon
        // as the gateway is confirmed connected so the generator doesn't race ahead
        // of the first timeline_event from the adapter.
        if s.gatewayConnected == true {
            timelineGenerator.receivingBridgeTimeline = true
        }

        // Local timeline generation (when bridge doesn't provide rich timeline)
        timelineGenerator.onStateUpdate(
            newState: s.state,
            agentType: e.agentType,
            currentTool: e.currentTool,
            toolInput: e.toolInput,
            question: e.question,
            projectName: e.projectName,
            sessionId: e.sessionId
        )
        timelineVersion += 1

        // Clear tool info on idle
        if s.state == .idle {
            s.currentTool = nil
            s.toolInput = nil
            s.toolProgress = nil
        }

        // Clear options when not awaiting
        if !s.state.isAwaiting {
            s.options = []
            s.question = nil
            s.promptType = nil
        }

        state = s
    }

    // MARK: - Usage Update

    private func handleUsageUpdate(_ e: UsageEvent) {
        // Local copy + single assignment — same @Published fan-out rationale as
        // handleStateUpdate.
        var s = state
        s.sessionDurationSec = e.sessionDurationSec ?? s.sessionDurationSec
        s.inputTokens = e.inputTokens ?? s.inputTokens
        s.outputTokens = e.outputTokens ?? s.outputTokens
        s.toolCalls = e.toolCalls ?? s.toolCalls
        s.estimatedCostUsd = e.estimatedCostUsd ?? s.estimatedCostUsd
        s.sessionPercent = e.sessionPercent ?? s.sessionPercent
        s.costSpent = e.costSpent ?? s.costSpent
        s.costLimit = e.costLimit ?? s.costLimit
        s.resetTime = e.resetTime ?? s.resetTime
        s.resetDate = e.resetDate ?? s.resetDate
        // Save previous values for trend indicators before overwriting
        if e.fiveHourPercent != nil { s.previousFiveHourPercent = s.fiveHourPercent }
        if e.sevenDayPercent != nil { s.previousSevenDayPercent = s.sevenDayPercent }
        // When upstream signals stale (no live source could produce a fresh
        // number) clear the displayed values entirely instead of retaining
        // the last-seen ones — a stale number in the UI is the worst of both
        // worlds (looks authoritative, but isn't). Downstream surfaces then
        // naturally collapse their usage regions on nil.
        if e.usageStale == true {
            s.fiveHourPercent = nil
            s.sevenDayPercent = nil
            s.fiveHourResetsAt = nil
            s.sevenDayResetsAt = nil
        } else {
            s.fiveHourPercent = e.fiveHourPercent ?? s.fiveHourPercent
            s.sevenDayPercent = e.sevenDayPercent ?? s.sevenDayPercent
            s.fiveHourResetsAt = e.fiveHourResetsAt ?? s.fiveHourResetsAt
            s.sevenDayResetsAt = e.sevenDayResetsAt ?? s.sevenDayResetsAt
        }
        s.extraUsageEnabled = e.extraUsageEnabled ?? s.extraUsageEnabled
        s.extraUsageMonthlyLimit = e.extraUsageMonthlyLimit ?? s.extraUsageMonthlyLimit
        s.extraUsageUsedCredits = e.extraUsageUsedCredits ?? s.extraUsageUsedCredits
        s.extraUsageUtilization = e.extraUsageUtilization ?? s.extraUsageUtilization
        s.oauthConnected = e.oauthConnected ?? s.oauthConnected
        if let os = e.ollamaStatus { s.ollamaStatus = os }
        s.usageStale = e.usageStale ?? s.usageStale
        let sawCodexAuthField = e.codexAuthMode != nil
            || e.codexWebAuthConnected != nil
            || e.codexPlanType != nil
            || e.codexAccountId != nil
            || e.codexSubscriptionActiveUntil != nil
            || e.codexLastRefreshAt != nil
        s.codexAuthMode = e.codexAuthMode ?? s.codexAuthMode
        s.codexWebAuthConnected = e.codexWebAuthConnected ?? s.codexWebAuthConnected
        s.codexPlanType = e.codexPlanType ?? s.codexPlanType
        s.codexAccountId = e.codexAccountId ?? s.codexAccountId
        s.codexSubscriptionActiveUntil = e.codexSubscriptionActiveUntil ?? s.codexSubscriptionActiveUntil
        s.codexLastRefreshAt = e.codexLastRefreshAt ?? s.codexLastRefreshAt
        s.codexRateLimits = e.codexRateLimits ?? s.codexRateLimits
        s.modelCatalog = e.modelCatalog ?? s.modelCatalog
        s.mlxModels = e.mlxModels ?? s.mlxModels
        s.mlxModelCatalog = e.mlxModelCatalog ?? s.mlxModelCatalog
        if let subscriptions = e.subscriptions {
            s.subscriptions = subscriptions
        } else {
            reconcileCodexSubscriptionFallback(clearWhenUnavailable: sawCodexAuthField, state: &s)
        }
        s.antigravityStatus = e.antigravityStatus ?? s.antigravityStatus

        s.adminApiKeyPresent = e.adminApiKeyPresent ?? s.adminApiKeyPresent
        s.adminApiTodayInputTokens = e.adminApiTodayInputTokens ?? s.adminApiTodayInputTokens
        s.adminApiTodayOutputTokens = e.adminApiTodayOutputTokens ?? s.adminApiTodayOutputTokens
        s.adminApiTodayCacheReadTokens = e.adminApiTodayCacheReadTokens ?? s.adminApiTodayCacheReadTokens
        s.adminApiTodayCacheCreationTokens = e.adminApiTodayCacheCreationTokens ?? s.adminApiTodayCacheCreationTokens
        s.adminApiMonthInputTokens = e.adminApiMonthInputTokens ?? s.adminApiMonthInputTokens
        s.adminApiMonthOutputTokens = e.adminApiMonthOutputTokens ?? s.adminApiMonthOutputTokens
        s.adminApiMonthCacheReadTokens = e.adminApiMonthCacheReadTokens ?? s.adminApiMonthCacheReadTokens
        s.adminApiMonthCacheCreationTokens = e.adminApiMonthCacheCreationTokens ?? s.adminApiMonthCacheCreationTokens
        s.adminApiTopModels = e.adminApiTopModels ?? s.adminApiTopModels
        s.adminApiFetchedAt = e.adminApiFetchedAt ?? s.adminApiFetchedAt
        s.adminApiStale = e.adminApiStale ?? s.adminApiStale
        state = s
    }

    /// Operates on the caller's local working copy (`inout`) so the batched
    /// single-assignment in handleStateUpdate/handleUsageUpdate stays intact.
    private func reconcileCodexSubscriptionFallback(clearWhenUnavailable: Bool, state s: inout DashboardState) {
        let nonChatGptSubscriptions = s.subscriptions.filter {
            !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("chatgpt")
        }
        guard let name = Self.chatGptSubscriptionName(
            planType: s.codexPlanType,
            authMode: s.codexAuthMode,
            webAuthConnected: s.codexWebAuthConnected,
            until: s.codexSubscriptionActiveUntil
        ) else {
            if clearWhenUnavailable {
                s.subscriptions = nonChatGptSubscriptions
            }
            return
        }

        var subscriptions = nonChatGptSubscriptions
        subscriptions.insert(SubscriptionInfo(name: name, until: s.codexSubscriptionActiveUntil), at: 0)
        s.subscriptions = subscriptions
    }

    private static func chatGptSubscriptionName(
        planType: String?,
        authMode: String?,
        webAuthConnected: Bool?,
        until: String?
    ) -> String? {
        if let plan = planType?.trimmingCharacters(in: .whitespacesAndNewlines), !plan.isEmpty {
            switch plan.lowercased() {
            case "plus": return "ChatGPT Plus"
            case "pro": return "ChatGPT Pro"
            case "team": return "ChatGPT Team"
            case "enterprise": return "ChatGPT Enterprise"
            default: return "ChatGPT \(plan)"
            }
        }
        let normalizedMode = authMode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        let hasUntil = until.map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false
        if webAuthConnected == true || normalizedMode == "chatgpt" || hasUntil {
            return "ChatGPT"
        }
        return nil
    }

    // MARK: - Connection

    private func handleConnection(_ e: ConnectionEvent) {
        switch e.status {
        case "connected":
            state.bridgeConnected = true
            state.sessionId = e.sessionId
            state.focusedSessionId = nil
            isAutoConnecting = false
            waterfallStage = .idle

            // Save successful URL for next launch
            if let url = connection.url {
                savedUrl = url
            }
        case "disconnected":
            resetToDisconnected()
        default:
            break
        }
    }

    /// Purge Node-daemon-relayed usage/subscription cache. Called on the
    /// external→owner promotion edge so features the sandboxed self-daemon
    /// cannot produce (Claude 5h/7d quota, ChatGPT/Google subscription rows)
    /// stop lingering as a stale trace once this app becomes its own daemon.
    ///
    /// Codex local limits + Antigravity plan name are cleared too, but the
    /// self-daemon RE-EMITS them within one 5s usage tick whenever it has local
    /// file access (a user-granted security-scoped bookmark) — so
    /// self-producible data reappears while only genuinely-Node-only data stays
    /// hidden. Deliberately NOT part of `resetToDisconnected`: that fires on
    /// transient external-daemon blips and would flicker legitimate data.
    func clearRelayedUsageState() {
        state.fiveHourPercent = nil
        state.fiveHourResetsAt = nil
        state.sevenDayPercent = nil
        state.sevenDayResetsAt = nil
        state.usageStale = nil
        state.codexAuthMode = nil
        state.codexPlanType = nil
        state.codexRateLimits = nil
        state.subscriptions = []
        state.antigravityStatus = nil
    }

    private func resetToDisconnected() {
        timelineGenerator.onDisconnected()
        timelineVersion += 1
        // Preserve lastKnownState for offline display
        state.bridgeConnected = false
        state.state = .disconnected
        state.sessionId = nil
        state.focusedSessionId = nil
        state.hostDisplayOn = true
        #if os(iOS)
        displaySync.restoreOnDisconnect()
        #endif
        state.currentTool = nil
        state.toolInput = nil
        state.toolProgress = nil
        state.options = []
        state.question = nil
    }

    // MARK: - Commands

    func sendCommand(_ command: PluginCommand) {
        guard !isTerminating else { return }
        connection.send(command)
    }

    // MARK: - Connection Management

    func connectTo(_ bridge: DiscoveredBridge) {
        guard !isTerminating else { return }
        connection.connect(to: bridge.wsUrl)
    }

    func connectTo(url: String) {
        guard !isTerminating else { return }
        connection.connect(to: url)
    }

    func setPreferredLocalBridge(url: String?) {
        guard !isTerminating else { return }
        preferredLocalBridgeUrl = url
        if let url {
            autoConnectTimer?.invalidate()
            autoConnectTimer = nil
            isAutoConnecting = false
            waterfallStage = .idle
            discovery.stopSearching()
            failedBridgeIds.removeAll()
            if connection.url != url {
                // Force-disconnect any in-progress reconnect loop on the old URL
                // before switching to the new one (e.g. daemon restarted on a different port).
                connection.disconnect()
                connectTo(url: url)
            } else if connection.status == .disconnected {
                connectTo(url: url)
            }
        }
    }

    func disconnectBridge() {
        guard !isTerminating else { return }
        connection.disconnect()
        resetToDisconnected()
        savedUrl = nil  // Clear saved URL on explicit disconnect
        preferredLocalBridgeUrl = nil  // Prevent auto-reconnect from onDisconnect handler
        waterfallStage = .idle
    }
}
