import SwiftUI
import NucleicCore
import IOKit.ps

/// The "start a new chat" composer: pick a project (with its model, effort, auto/ship,
/// and base branch/worktree), type the first message, and start a chat. Shared by two
/// hosts — pinned inline at the bottom of the home dashboard, and floated over the
/// window as the ⌘N / ⌘⇧N quick-launch panel (`NewChatComposerSheet`). Both drive the same
/// state and `start()`, so the two presentations can't drift.
struct NewChatComposer: View {
    @Environment(AppStore.self) private var store
    @Environment(\.dismiss) private var dismiss
    /// Persisted so the composer reopens on the last project you picked — shared by the
    /// home bar and the floating panel so they agree on the selection.
    @AppStorage("nucleic.home.lastProjectID") private var lastProjectID = ""
    @AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
    /// Drives the composer's accent: lavender for a Nucleic Control project, matching how
    /// that project's own session is themed (read straight from the same setting `RootView` uses).
    @AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
    /// Intelligence routing (the slider): on by default; the Settings toggle restores the
    /// manual Model + Effort menus. A manual pick from the resolved chip also suspends
    /// routing for this one chat (see `manualOverrideActive`).
    @AppStorage(ModelCatalog.intelligenceSliderEnabledKey) private var intelligenceSliderEnabled = true
    /// Provider pin for the router ("" = any connected provider).
    @AppStorage(ModelCatalog.intelligencePinnedProviderKey) private var pinnedProviderRaw = ""

    /// The first-message draft. The home bar binds this to `store.homeDraft` (persists across
    /// navigation); the floating panel binds a transient local draft that clears on dismiss.
    @Binding var draft: String
    /// Floated over the window (⌘N / ⌘⇧N) rather than pinned inline: adds a titled header with a
    /// Cancel action, sizes to a fixed panel width, and dismisses itself once a chat starts.
    var floating = false
    /// When floating, whether starting the chat leaves the user on their current view (⌘N)
    /// rather than jumping to the new session (⌘⇧N). Ignored for the inline home bar.
    var background = false
    /// Seed the composer straight into ephemeral (Scratch) chat mode — set by the "New Scratch
    /// Chat" command. Applied once on appear so the user can still toggle it off.
    var initialEphemeral = false
    /// Floor under the text entry's height. The home dashboard opens its composer several lines
    /// tall so the page reads as "start a chat here" rather than as a dashboard that happens to
    /// carry an input bar; everywhere else leaves it at `ChatInputField.restingHeight`. The field
    /// still grows past this with the text.
    var minEntryHeight: CGFloat = 0

    @State private var composerHeight = ChatInputField.restingHeight
    /// Files/images staged in the composer (attach button, paste, or drag) to send with the
    /// first message of the new chat; materialized into its worktree once it's created.
    @State private var attachments: [ComposerAttachment] = []
    /// True while a file/image drag hovers the composer, to light up the drop zone.
    @State private var composerDropTargeted = false
    @State private var showingAddProject = false
    @State private var modelOverride: String?
    @State private var effortOverride: String?
    @State private var branches: [String] = []
    @State private var selectedBranch: String?
    @State private var useWorktree = true
    /// Covalence (mesh work queue): run the new chat Here (false) or on Covalence (true) — auto-routed
    /// to the best eligible host on the mesh *other than this Mac*, and kept on the optimal host
    /// between turns. Shown only when a connected peer could actually take it.
    @State private var runOnMesh = false
    /// Chat mode: when on, this composer starts an ephemeral, out-of-project (Scratch) chat instead
    /// of a project chat — the project/branch/worktree/ship/attach affordances are hidden and the
    /// chat opens in its own popup window. Toggled by the "Ephemeral" button.
    @State private var ephemeralMode = false
    /// nil until the user explicitly toggles, so the button reflects the app default.
    @State private var autoOverride: Bool?
    @State private var shipOverride: Bool?
    /// False until the composer's initial async state (project resolution, branch load) has
    /// settled, so the rows lay out in their final positions on open instead of animating in
    /// from a looser spacing and snapping into place. Flipped true after the first load so
    /// genuine interactions afterward (e.g. the Ship toggle) still animate.
    @State private var layoutAnimationsEnabled = false
    /// The Intelligence slider's level for the chat about to start (seeded from the Settings
    /// default; per-composer state like `modelOverride`).
    @State private var intelligenceLevel = ModelCatalog.storedDefaultIntelligenceLevel
    /// Live Orchestra preview from the slider while its binding is intentionally uncommitted.
    /// The composer glow follows this just like the slider label does.
    @State private var orchestraPreviewActive: Bool?
    /// Classifies the draft's purpose as the user types (debounced), so routing is already
    /// resolved by send time. Created on appear — it captures the store's provider.
    @State private var purposeService: PromptPurposeService?

    /// Width reserved for the send button, so the bottom controls row can be
    /// inset to line up with the text field's trailing edge.
    private let sendButtonWidth: CGFloat = 28

    private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }

    var body: some View {
        content
            // Hold animations off while the composer's opening state settles (see
            // `layoutAnimationsEnabled`); a value-based `.animation` lower in the tree would
            // otherwise re-animate that first settle, so it's gated on the same flag.
            .transaction { if !layoutAnimationsEnabled { $0.animation = nil } }
            .task(id: selectedSummary?.id) {
                await loadBranches()
                layoutAnimationsEnabled = true
            }
            // Classify-as-you-type: the id-keyed task auto-cancels on every material draft
            // change, so the sleep is the debounce; only the draft the user pauses on is
            // classified. Keyed on a whitespace-normalized hash so rewraps don't retrigger.
            .task(id: PromptPurposeService.normalizedHash(draft)) {
                guard intelligenceRoutingActive else { return }
                try? await Task.sleep(for: .milliseconds(700))
                guard !Task.isCancelled else { return }
                await purposeService?.draftChanged(draft)
            }
            .onChange(of: orchestraSelected) { _, selected in
                orchestraPreviewActive = nil
                if selected { intelligenceLevel = .max }
            }
            .onAppear {
                if initialEphemeral { ephemeralMode = true }
                if orchestraSelected { intelligenceLevel = .max }
                if purposeService == nil {
                    purposeService = PromptPurposeService(intelligence: store.intelligence)
                }
                // Keep the router's connected-provider view fresh (a login since launch
                // should widen routing without a relaunch).
                Task { await store.refreshConnectedProviders() }
            }
            .sheet(isPresented: $showingAddProject) { AddProjectSheet() }
    }

    @ViewBuilder
    private var content: some View {
        if floating {
            VStack(alignment: .leading, spacing: 14) {
                HStack {
                    Text("New chat").font(.headline)
                    Spacer()
                    // Provider service status (Claude / OpenAI / xAI) — a glanceable health pill,
                    // expanding to the current incidents on click/hover. Sits in the header, having
                    // traded places with the Ephemeral/Cancel cluster now on the project row below.
                    StatusFeedIndicator(palette: composerPalette)
                }
                composerCore
            }
            .padding(20)
            .frame(width: 720)
            // The destination selector replaced the Cancel button on the project row, so carry
            // Cancel's Escape-to-dismiss here (clicking outside the panel also dismisses — see
            // NewChatComposerSheet).
            .onExitCommand { dismiss() }
        } else {
            composerCore.padding(12)
        }
    }

    /// Chat-mode toggle: flip the composer between a project chat and an ephemeral,
    /// out-of-project (Scratch) chat. In chat mode the project/branch/worktree/ship/attach
    /// affordances below are hidden and the chat opens in its own popup window.
    private var ephemeralToggle: some View {
        Toggle(isOn: $ephemeralMode) {
            Label("Ephemeral", systemImage: "bolt")
        }
        .toggleStyle(.button)
        .tint(ephemeralMode ? composerPalette.accent : nil)
        .fixedSize()
        .help(ephemeralMode
            ? "Chat mode: starts a throwaway, out-of-project chat in a scratch workspace (its own window). Click to return to a project chat."
            : "Start a throwaway, out-of-project chat in a scratch workspace instead of a project.")
    }

    /// Destination selector (Covalence mesh work queue): "Here" runs the new chat on this Mac —
    /// its glyph tracks this Mac's form factor so it reads as *this* machine; "Covalence" routes
    /// it to the best OTHER host on the Covalence mesh (never this Mac) with an acknowledged,
    /// idempotent start, and keeps it on the optimal host between turns. Offered whenever a runner
    /// is live OR the Covalence Cloud runner is enabled (`showsMeshSelector`) — picking Covalence
    /// with nothing running auto-provisions one. Hidden in the common single-Mac case so the
    /// composer stays uncluttered. Rendered inline on the project row for the pinned home bar, and
    /// on the row's trailing edge (in place of the old Cancel button) for the floating panel.
    @ViewBuilder
    private var destinationMenu: some View {
        if showsMeshSelector {
            Menu {
                Picker("Run on", selection: $runOnMesh) {
                    Text("Here").tag(false)
                    Text("Covalence").tag(true)
                }
                .pickerStyle(.inline)
            } label: {
                HStack(spacing: 4) {
                    Image(systemName: runOnMesh ? "circle.dotted.and.circle" : LocalMachine.symbolName)
                    Text(runOnMesh ? "Covalence" : "Here")
                    Image(systemName: "chevron.down").font(.caption2)
                }
            }
            .menuStyle(.button)
            .menuIndicator(.hidden)
            .fixedSize()
            .help("Where the new chat runs: Here (this Mac), or Covalence — auto-routed to the best other host on your Covalence mesh and moved between hosts as availability changes.")
        }
    }

    /// The composer proper — the project/branch row, the text field with its send button,
    /// and the mode/model/effort row. Identical in both presentations.
    private var composerCore: some View {
        VStack(spacing: 8) {
            HStack(spacing: 8) {
                // In the floating presentation the Ephemeral toggle lives on the project row's
                // trailing edge next to Cancel; inline (non-floating) it leads this controls row.
                if !floating { ephemeralToggle }
                if !ephemeralMode {
                    Menu {
                        // One picker across the mesh (mesh session sync): local and peer-Mac
                        // projects interleave name-sorted, a globe marking the remote ones —
                        // matching the sidebar exactly.
                        ForEach(store.sidebarProjects, id: \.rowID) { project in
                            Button {
                                lastProjectID = project.id.rawValue
                            } label: {
                                if project.isRemote {
                                    Label(project.name, systemImage: "globe")
                                } else {
                                    Text(project.name)
                                }
                            }
                        }
                        if !store.sidebarProjects.isEmpty { Divider() }
                        Button("New Project…", systemImage: "folder.badge.plus") {
                            showingAddProject = true
                        }
                    } label: {
                        HStack(spacing: 4) {
                            Image(systemName: "folder")
                            Text(selectedSummary?.name ?? "Select a project")
                            if isRemoteProject {
                                Image(systemName: "globe").font(.caption2).foregroundStyle(.secondary)
                            }
                            Image(systemName: "chevron.down").font(.caption2)
                        }
                    }
                    .menuStyle(.button)
                    // The button menu style draws its own disclosure chevron; we supply a
                    // custom-styled one in the label above, so hide the built-in to avoid
                    // a double chevron.
                    .menuIndicator(.hidden)
                    .fixedSize()
                    // nvrsion projects have no per-session base branch and no per-session worktree —
                    // every session runs on the one shared workspace — so neither the branch picker
                    // nor the worktree toggle has anything to control; hide both. A remote project
                    // hides them too: the base branch and worktree are the owner's affairs (the
                    // wire start uses the owner's defaults).
                    if selectedProject?.nvrsionActive != true && !isRemoteProject {
                        branchMenu
                        Toggle(isOn: $useWorktree) {
                            Label("Worktree", systemImage: "arrow.triangle.branch")
                        }
                        .toggleStyle(.checkbox)
                        // Pin to its natural width like every sibling control in this row;
                        // without it the checkbox toggle is the one flexible child, so an
                        // over-budget row compresses it and wraps "Worktree" vertically.
                        .fixedSize()
                        .help(useWorktree
                            ? "Run on an isolated worktree branched from \(effectiveBranch)."
                            : "Run directly in the main checkout on \(effectiveBranch) — no isolation.")
                    }
                    // The destination (Here/Covalence) selector sits inline here only for the
                    // pinned home bar; the floating panel renders it on the row's trailing edge in
                    // place of Cancel, so keep it off the left cluster there.
                    if !floating { destinationMenu }
                }
                Spacer()
                // Floating presentation only: the Ephemeral toggle and the destination selector
                // (which replaced the old Cancel button) live here on the project row's trailing
                // edge, having traded places with the status pill now up in the header. Inline
                // (no header) the status pill stays put at the row's trailing edge.
                if floating {
                    ephemeralToggle
                    destinationMenu
                } else {
                    // Provider service status (Claude / OpenAI / xAI) — a glanceable health pill,
                    // expanding to the current incidents on click/hover. Trailing-inset so its right
                    // edge lines up with the text field's trailing edge (matching the controls row).
                    StatusFeedIndicator(palette: composerPalette)
                }
            }
            .font(.callout)
            // Line the row's trailing element up with the text field's trailing edge
            // (sendButtonWidth + 8, matching the controls row below). Inline, that element is
            // the status pill, which gets an extra optical nudge left so it doesn't read as
            // hugging the right edge; floating, it's the destination selector button, which
            // should sit flush with the text entry's edge — so no nudge there.
            .padding(.trailing, sendButtonWidth + (floating ? 8 : 20))
            // Staged attachments (attach button / paste / drag), shown above the field.
            if !attachments.isEmpty {
                ComposerAttachmentBar(attachments: $attachments)
                    .padding(.trailing, sendButtonWidth + 8)
            }
            HStack(alignment: .bottom, spacing: 8) {
                ChatInputField(
                    text: $draft,
                    placeholder: ephemeralMode ? "Start a scratch chat…" : "Start a new chat…",
                    submitMode: submitMode, isEnabled: ephemeralMode || selectedSummary != nil,
                    height: $composerHeight, onSend: { start(background: $0) },
                    // Paste/drag can't stage files for a remote or ephemeral chat — they don't ride
                    // the mesh start verb / have no worktree (the attach button is already disabled).
                    onAttach: { if !isRemoteProject && !ephemeralMode { attachments.append(contentsOf: $0) } },
                    onDropTargetedChanged: { composerDropTargeted = $0 })
                    .frame(height: max(composerHeight, minEntryHeight))
                    .padding(8)
                    .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
                    // A persistent lavender ring cues a Nucleic Control project. When Orchestra
                    // is on, its own purple glow owns the ring, so don't double it up.
                    .overlay {
                        if isControlProject && !composerOrchestraActive {
                            RoundedRectangle(cornerRadius: 8)
                                .strokeBorder(composerPalette.accent.opacity(0.55), lineWidth: 1)
                                .allowsHitTesting(false)
                        }
                    }
                    // Light up the field's border while a file/image drag hovers it.
                    .overlay {
                        if composerDropTargeted {
                            RoundedRectangle(cornerRadius: 8)
                                .strokeBorder(composerPalette.accent, lineWidth: 2)
                                .allowsHitTesting(false)
                        }
                    }
                    .orchestraGlow(active: composerOrchestraActive)
                // Send, then the ⬇︎ that drops the prompt into the stash box a row below (on
                // the model/effort line, same column) — so it reads as falling into the box.
                VStack(spacing: 6) {
                    Button { start(background: background) } label: {
                        SubmitKeyIcon(mode: submitMode)
                    }
                    .buttonStyle(.plain)
                    .help(routedRouteAvailable ? submitMode.detail : routedResolution.reason)
                    .disabled(!canStart)
                    PromptStashDropButton(canStash: hasStashableDraft, onStash: stashDraft)
                }
                .frame(width: sendButtonWidth)
            }
            HStack(spacing: 8) {
                autoToggle
                // Shipping implies Auto, so the Ship toggle is only meaningful when Auto
                // is on. It slides out from behind the Auto button when Auto is enabled
                // and tucks back under it when Auto is turned off. Under nvrsion the trunk is
                // promoted at the project level, so a per-chat Merge toggle is meaningless
                // and hidden entirely.
                // Hidden for a remote project too: autoship is configured on the owner (the
                // wire start carries no ship flag), so a toggle here would promise a merge
                // the owner may not run.
                if effectiveAuto && !nvrsionActive && !isRemoteProject && !ephemeralMode {
                    shipToggle
                        .transition(.move(edge: .leading).combined(with: .opacity))
                }
                // Attach files/images. Sits with the left-hand controls (not beside the field) so
                // it never indents the composer. Disabled for a remote project — attachments
                // materialize into a local worktree, and they don't ride the mesh start verb — and
                // for an ephemeral chat, which has no project worktree to stage them into.
                ComposerAttachButton(isEnabled: selectedProject != nil && !ephemeralMode) { picked in
                    attachments.append(contentsOf: picked)
                }
                Spacer()
                if intelligenceRoutingActive {
                    IntelligenceSlider(
                        level: $intelligenceLevel,
                        orchestraTier: orchestraTierBinding,
                        enabled: routedRouteAvailable,
                        orchestraAvailable: isControlProject,
                        animationsSettled: layoutAnimationsEnabled,
                        composerText: draft,
                        onOrchestraPreviewChanged: { orchestraPreviewActive = $0 })
                    resolvedChip
                } else {
                    modelMenu
                    effortMenu
                }
                // The stash box, in the send button's column so the ⬇︎ above drops straight
                // into it. It takes the place of this row's trailing inset rather than adding
                // height beside the text field.
                PromptStashBox(accent: composerPalette.accent, onPull: pull)
                    .frame(width: sendButtonWidth)
            }
            .animation(layoutAnimationsEnabled ? .easeInOut(duration: 0.25) : nil, value: effectiveAuto)
            .font(.callout)
        }
    }

    private func loadBranches() async {
        guard let project = selectedProject else { branches = []; return }
        branches = await store.branches(in: project)
        if let current = selectedBranch, !branches.contains(current) { selectedBranch = nil }
    }

    /// Base branch a new chat would start from: the explicit pick, else the
    /// project's default branch.
    private var effectiveBranch: String {
        selectedBranch ?? selectedProject?.defaultBranch.value ?? "main"
    }

    /// The project a new chat would start in — anywhere in the mesh (mesh session sync): the
    /// last-picked project (persisted), resolved over the unified list so a peer Mac's project
    /// is pickable exactly like a local one. A remembered pick that can't be resolved (its
    /// peer is disconnected, or the project is gone) fails CLOSED to no selection rather than
    /// silently retargeting an unrelated project — a typed prompt must never start an agent
    /// in a repo it wasn't written for. Only a fresh composer (nothing remembered) defaults
    /// to the store's current/first local project.
    private var selectedSummary: ProjectSummary? {
        guard !lastProjectID.isEmpty else { return store.currentProject.map(ProjectSummary.init) }
        return store.sidebarProjects.first { $0.id.rawValue == lastProjectID }
    }

    /// Whether the selected project lives on a peer Mac. Gates the local-only affordances
    /// (base branch, worktree toggle, attachments, ship) — the chat itself starts on the
    /// owner over the mesh.
    private var isRemoteProject: Bool { selectedSummary?.isRemote == true }

    /// The full local record behind the selection, when it lives on this Mac — branch
    /// loading, attachments, and ship act on it. `nil` for a peer Mac's project.
    private var selectedProject: Project? {
        guard let summary = selectedSummary, !summary.isRemote else { return nil }
        return store.project(summary.id)
    }

    /// Whether the destination (Here/Covalence) selector is offered for the current selection: a
    /// local project this Mac could actually dispatch — a live runner, or the Covalence Cloud
    /// runner enabled. Single source of truth for the selector's presence in both presentations.
    private var showsMeshSelector: Bool {
        guard !ephemeralMode, !isRemoteProject, let project = selectedProject else { return false }
        return store.meshDispatchPossible(for: project)
    }

    // Effective concrete values for the chat about to be started: the user's
    // override, or the app default (never shows "Default").
    private var effectiveModel: String {
        // In Orchestra the parent chat is the supervisor, so it runs the configured supervisor
        // model rather than the composer's pick (workers run the worker model). Kept in step with
        // the parent-model resolution in `AppStore.createSession`. Uses `orchestraSelected`, not
        // `isOrchestra`, to avoid recursing through this property.
        if orchestraSelected {
            return store.defaultSupervisorModel ?? ModelCatalog.fallbackSupervisorModel
        }
        return modelOverride ?? store.defaultModel ?? ModelCatalog.fallbackModel
    }
    // Clamp to what the chosen model supports, so a stored "max" can't survive onto a Codex model.
    private var effectiveEffort: String {
        let raw = ModelCatalog.clampedEffort(
            effortOverride ?? store.defaultEffort ?? ModelCatalog.fallbackEffort,
            for: effectiveModel, codexPro: store.isCodexPro)
        // Orchestra requires Nucleic Control; off-control (e.g. an override carried over when the
        // project picker changes) it falls back to its underlying level, so the menu never shows
        // it active — and a started chat never carries it — where it can't run.
        if ModelCatalog.isOrchestra(raw), !isControlProject {
            return OrchestrationMode.resolvedEffort(raw) ?? ModelCatalog.fallbackEffort
        }
        return raw
    }
    private var defaultModel: String { store.defaultModel ?? ModelCatalog.fallbackModel }
    private var defaultEffort: String { store.defaultEffort ?? ModelCatalog.fallbackEffort }

    /// Menu item: checkmark on the selected value, a "house" on the app default.
    @ViewBuilder
    private func choiceLabel(_ display: String, badge: String? = nil, isSelected: Bool, isDefault: Bool) -> some View {
        let title = Text(display)
            + (badge.map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
            + (isDefault ? Text("  (default)") : Text(""))
        if isSelected {
            Label { title } icon: { Image(systemName: "checkmark") }
        } else if isDefault {
            Label { title } icon: { Image(systemName: "house") }
        } else {
            title
        }
    }

    private var modelMenu: some View {
        Menu {
            // The way back to routed mode after a manual pick suspended it for this chat
            // (shown only then — in plain manual mode Settings owns the switch).
            if intelligenceSliderEnabled && manualOverrideActive {
                Button("Use Intelligence Slider", systemImage: OrchestraStyle.symbol) {
                    modelOverride = nil
                    effortOverride = nil
                }
                Divider()
            }
            // Group the SKUs by provider (Claude, GPT, Grok), set apart with a divider.
            ForEach(Array(ModelCatalog.modelGroups.enumerated()), id: \.offset) { index, group in
                if index > 0 { Divider() }
                ForEach(group, id: \.self) { sku in
                    Button { modelOverride = sku } label: {
                        choiceLabel(ModelCatalog.displayName(sku),
                                    badge: ModelCatalog.contextBadge(for: sku),
                                    isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
                    }
                }
            }
        } label: {
            Text("Model: \(ModelCatalog.displayName(effectiveModel))")
                + (ModelCatalog.contextBadge(for: effectiveModel).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
        }
        .menuStyle(.button)
        .fixedSize()
        // Orchestra fixes the parent to the supervisor model, so the pick is inert then — disable
        // it and say why, rather than letting a change silently do nothing.
        .disabled(orchestraSelected)
        .help(orchestraSelected
            ? "Orchestra runs the supervisor model set in Settings › Agents."
            : "Model for the new chat")
    }

    /// Whether the chat about to start runs in orchestra (orchestration) mode.
    private var isOrchestra: Bool { ModelCatalog.isOrchestra(effectiveEffort) }

    private var composerOrchestraActive: Bool {
        orchestraPreviewActive ?? orchestraSelected
    }

    /// Whether Orchestra is the selected mode, computed *without* going through `effectiveModel`
    /// (which swaps in the supervisor model for Orchestra) so the two don't recurse. Orchestra is
    /// control-gated, so off-control it's never active — mirrors `effectiveEffort`'s demotion.
    private var orchestraSelected: Bool {
        isControlProject
            && ModelCatalog.isOrchestra(effortOverride ?? store.defaultEffort ?? ModelCatalog.fallbackEffort)
    }

    /// Bridge the Max constellation to the existing Orchestra effort sentinel. Orchestra always
    /// carries Max Intelligence; the persisted effort remains backward-compatible.
    private var orchestraTierBinding: Binding<IntelligenceOrchestraTier?> {
        Binding(
            get: {
                guard orchestraSelected else { return nil }
                return .orchestra
            },
            set: { tier in
                if let tier {
                    intelligenceLevel = tier.level
                    effortOverride = ModelCatalog.orchestraEffort
                } else if orchestraSelected {
                    effortOverride = nil
                }
            })
    }

    /// Whether the selected project is under Nucleic Control — the gate for Orchestra and the
    /// cue for the composer's lavender accent. Resolved from the summary so a Control project
    /// on a peer Mac themes and gates identically to a local one (its owner applies the same
    /// control gate when the chat actually starts).
    private var isControlProject: Bool { !ephemeralMode && selectedSummary?.isNucleicControlled == true }

    /// Whether the selected project is governed by nvrsion (shared trunk). When it is, the new
    /// chat lands on the shared trunk and merges by project-level promotion, so a per-chat Merge
    /// toggle is meaningless and hidden — mirrors the in-session composer.
    private var nvrsionActive: Bool { selectedProject?.nvrsionActive == true }

    /// The accent palette for the composer: lavender when the selected project is Nucleic
    /// Control (matching how that project's own session is themed), the app default (teal)
    /// otherwise. Recomputed as the project picker changes, so the accent tracks the selection.
    private var composerPalette: AppPalette {
        AppPalette.make(ColorVisionMode(rawValue: colorVisionRaw) ?? .standard, controlled: isControlProject)
    }

    private var effortMenu: some View {
        Menu {
            ForEach(
                ModelCatalog.efforts(for: effectiveModel, codexPro: store.isCodexPro),
                id: \.self
            ) { level in
                Button { effortOverride = level } label: {
                    choiceLabel(ModelCatalog.effortDisplayName(level),
                                isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
                }
            }
            // Orchestra sits below the API levels, set apart: it isn't an effort level but an
            // orchestration mode (xhigh + standing consent to fan out to subagents). It's a
            // Nucleic Control capability, so it's only selectable for a Control project — shown
            // but disabled (with the reason) otherwise so the requirement is discoverable.
            Divider()
            Button { effortOverride = ModelCatalog.orchestraEffort } label: {
                orchestraMenuItem(available: isControlProject)
            }
            .disabled(!isControlProject)
            .help(isControlProject ? ModelCatalog.orchestraBlurb : ModelCatalog.orchestraRequiresControlHelp)
        } label: {
            if isOrchestra {
                OrchestraEffortLabel()
            } else {
                Text("\(ModelCatalog.effortNoun(for: effectiveModel)): \(ModelCatalog.effortDisplayName(effectiveEffort))")
            }
        }
        .menuStyle(.button)
        .fixedSize()
        .help(isOrchestra ? ModelCatalog.orchestraBlurb : "Reasoning effort for the new chat")
    }

    /// The Orchestra row in an effort menu: a sparkles glyph (a checkmark when it's the selected
    /// mode), "Orchestra", and either the "(default)" suffix, or — when unavailable because the
    /// project isn't under Nucleic Control — a grayed "Requires Nucleic Control" note.
    @ViewBuilder
    private func orchestraMenuItem(available: Bool) -> some View {
        let selected = isOrchestra
        let isDefault = ModelCatalog.isOrchestra(defaultEffort)
        Label {
            Text("Orchestra")
                + (available
                    ? (isDefault ? Text("  (default)") : Text(""))
                    : Text("  — \(ModelCatalog.orchestraRequiresControlNote)").foregroundColor(.secondary))
        } icon: {
            Image(systemName: selected ? "checkmark" : OrchestraStyle.symbol)
        }
    }

    // MARK: - Intelligence routing

    /// Whether the user has manually pinned a model/effort for this chat (via the resolved
    /// chip's menu, or a leftover override from manual mode) — which suspends routing for
    /// this chat only. An orchestra `effortOverride` is NOT a manual override: Orchestra
    /// lives on the slider's Max constellation and keeps routed mode active.
    private var manualOverrideActive: Bool {
        modelOverride != nil
            || (effortOverride != nil && !ModelCatalog.isOrchestra(effortOverride))
    }

    /// Whether this composer routes model/effort through the Intelligence slider.
    private var intelligenceRoutingActive: Bool {
        intelligenceSliderEnabled && !manualOverrideActive
    }

    /// The Settings provider pin, resolved ("" = any connected).
    private var pinnedProvider: BackendID? {
        pinnedProviderRaw.isEmpty ? nil : BackendID(rawValue: pinnedProviderRaw)
    }

    /// Backends whose provider currently reports an unresolved status incident — the router
    /// demotes (never drops) their candidates.
    private var degradedProviders: Set<BackendID> {
        var degraded = Set<BackendID>()
        for feed in store.statusFeeds where feed.hasActiveIncident {
            switch feed.provider {
            case .claude: degraded.insert(.claudeCode)
            case .openai: degraded.insert(.codex)
            case .xai: degraded.insert(.grok)
            }
        }
        return degraded
    }

    /// What the router would run right now — drives the resolved chip. Before the first
    /// classification lands this shows the `general` row's pick for the current level, so
    /// the chip is always a truthful "this is what send would do".
    private var routedResolution: IntelligenceRouter.Resolution {
        IntelligenceRouter.route(
            purpose: purposeService?.verdict?.purpose ?? .general,
            level: intelligenceLevel,
            connected: store.connectedProviders,
            pinned: pinnedProvider,
            degraded: degradedProviders,
            limits: store.intelligenceRoutingLimits,
            codexPro: store.isCodexPro,
            fallback: (defaultModel, defaultEffort))
    }

    /// A compact manual escape hatch beside the slider. The routed model used to be the
    /// menu label, but its changing text width pushed the slider left and right as routing
    /// changed. Keep the resolved choice in the tooltip and give the row a stable,
    /// icon-sized override button instead.
    private var resolvedChip: some View {
        let resolution = routedResolution
        let verdict = purposeService?.verdict
        return Menu {
            ForEach(Array(ModelCatalog.modelGroups.enumerated()), id: \.offset) { index, group in
                if index > 0 { Divider() }
                ForEach(group, id: \.self) { sku in
                    Button { modelOverride = sku } label: {
                        choiceLabel(ModelCatalog.displayName(sku),
                                    badge: ModelCatalog.contextBadge(for: sku),
                                    isSelected: false, isDefault: sku == defaultModel)
                    }
                }
            }
        } label: {
            Image(systemName: "slider.horizontal.3")
                .frame(width: 16, height: 16)
        }
        .menuStyle(.borderlessButton)
        .menuIndicator(.hidden)
        .controlSize(.small)
        .fixedSize()
        .disabled(orchestraSelected)
        .accessibilityLabel("Override routed model")
        .help(orchestraSelected
            ? "Orchestra runs the supervisor model set in Settings › Agents."
            : "Routed to \(ModelCatalog.displayName(resolution.model)) · "
                + "\(ModelCatalog.effortDisplayName(resolution.effort)). \(resolution.reason). "
                + (verdict.map { "Classified: \($0.purpose.displayName.lowercased()) (\($0.reason))." }
                    ?? "Type a prompt and it re-routes to match. ")
                + "Click to override the model for this chat.")
    }

    /// The concrete (model, effort) the chat starts with, plus the routing note when the
    /// Intelligence router made the pick. Routed sends resolve against the *final* draft —
    /// a cached verdict answers instantly; an in-flight classification gets a short grace
    /// wait; otherwise the deterministic heuristic answers (never a visible stall).
    private func resolveStartSelection(for text: String) async -> (model: String, effort: String, routing: RoutingNote?) {
        guard intelligenceRoutingActive, !orchestraSelected, let service = purposeService else {
            return (effectiveModel, effectiveEffort, nil)
        }
        let verdict = await service.resolveForSend(text)
        let resolution = IntelligenceRouter.route(
            purpose: verdict.purpose,
            level: intelligenceLevel,
            connected: store.connectedProviders,
            pinned: pinnedProvider,
            degraded: degradedProviders,
            limits: store.intelligenceRoutingLimits,
            codexPro: store.isCodexPro,
            fallback: (defaultModel, defaultEffort))
        let classification = "Classified by \(verdict.source.rawValue): \(verdict.reason)"
        let routing = RoutingNote(
            purpose: resolution.purpose,
            level: resolution.level,
            reason: "\(resolution.reason). \(classification)")
        return (resolution.model, resolution.effort, routing)
    }

    /// Auto-approve mode for the chat about to be started; defaults to the app-wide setting
    /// until the user toggles it, but is forced on for a Nucleic Control project — those chats
    /// run autonomously and Auto is locked on there. Mirrors the in-session button.
    private var effectiveAuto: Bool { isControlProject || (autoOverride ?? store.defaultAuto) }

    private var autoToggle: some View {
        // Auto is locked on for Nucleic Control projects (nvrsion can't function without it), so
        // disable the toggle there. Off-control it stays a normal toggle.
        let locked = isControlProject
        let lockedHelp = "Auto-approve is always on for Nucleic Control chats — they run autonomously."
        return Button {
            autoOverride = !effectiveAuto
        } label: {
            Label {
                Text("Auto")
            } icon: {
                Image(systemName: effectiveAuto ? "bolt.fill" : "bolt.slash")
                    // These symbols have different intrinsic bounds. Keep their layout
                    // footprint fixed so toggling Auto cannot resize the composer row.
                    .frame(width: 16, height: 16)
            }
            .foregroundStyle(effectiveAuto ? composerPalette.accent : Color.secondary)
        }
        .buttonStyle(.bordered)
        .fixedSize()
        .disabled(locked)
        .help(locked
            ? lockedHelp
            : (effectiveAuto
                ? "Auto-approve mode on: the new chat auto-approves safe actions; destructive ones still ask."
                : "Manual approvals: every gated tool in the new chat asks first."))
        // macOS suppresses .help on disabled controls, so overlay a transparent hit area to
        // carry the explanation when Auto is locked on under Nucleic Control.
        .overlay {
            if locked {
                Color.clear
                    .contentShape(Rectangle())
                    .help(lockedHelp)
            }
        }
    }

    /// Whether autoship can be enabled for the new chat: only Nucleic Control projects may
    /// autoship (the hardened, sandboxed path). Mirrors the in-session gate.
    private var shipAvailable: Bool { selectedProject?.isNucleicControlled == true }

    /// Autoship for the chat about to be started; defaults to the app-wide setting, but is
    /// clamped off for a non-Control project so the UI never promises a merge Core won't run.
    private var effectiveShip: Bool { (shipOverride ?? store.defaultAutoShip) && shipAvailable }

    private var shipToggle: some View {
        Button {
            guard shipAvailable else { return }
            shipOverride = !effectiveShip
        } label: {
            Label {
                Text("Ship")
            } icon: {
                Image(systemName: effectiveShip ? "shippingbox.fill" : "shippingbox")
                    // Filled and unfilled variants report slightly different bounds.
                    // A stable footprint prevents the bordered button and bar from jumping.
                    .frame(width: 16, height: 16)
            }
            .foregroundStyle(effectiveShip ? composerPalette.accent : Color.secondary)
        }
        .buttonStyle(.bordered)
        .fixedSize()
        .disabled(!shipAvailable)
        .help(shipAvailable
            ? (effectiveShip
                ? "Autoship on: when the new chat's agent finishes, squash-merge its branch into the destination branch via the merge queue (keeps Auto on)."
                : "Autoship off: finished work waits for a manual merge.")
            : "Autoship requires Nucleic Control — clone or move this project under Nucleic Control to enable it.")
        // macOS suppresses .help tooltips on disabled controls, so overlay an enabled,
        // transparent hit area to carry the explanation when Merge is grayed out off-control.
        .overlay {
            if !shipAvailable {
                Color.clear
                    .contentShape(Rectangle())
                    .help("Autoship/automerge is only available for Nucleic Control projects — clone or move this project under Nucleic Control to enable it.")
            }
        }
    }

    private var branchMenu: some View {
        Menu {
            ForEach(branches, id: \.self) { name in
                Button { selectedBranch = name } label: {
                    choiceLabel(name, isSelected: name == effectiveBranch,
                                isDefault: name == selectedProject?.defaultBranch.value)
                }
            }
        } label: {
            // Mirror the project selector to its left (icon + name + chevron) so the
            // two read as the same control; a plain Text label rendered as a near-
            // invisible white field against the chat bar.
            HStack(spacing: 4) {
                Image(systemName: "arrow.triangle.branch")
                Text(effectiveBranch)
                Image(systemName: "chevron.down").font(.caption2)
            }
        }
        .menuStyle(.button)
        // See the project selector above: hide the built-in disclosure chevron so
        // the custom one in the label isn't doubled up.
        .menuIndicator(.hidden)
        .fixedSize()
        .disabled(branches.isEmpty)
        .help("Base branch the new chat starts from")
    }

    /// Whether quota-aware routing has a usable destination. Orchestra and manual mode
    /// own their model selection, so only an active Intelligence route is gated here.
    private var routedRouteAvailable: Bool {
        !intelligenceRoutingActive || orchestraSelected || routedResolution.isAvailable
    }

    /// Whether the new chat can start: a typed prompt, or staged attachments on their own (an
    /// attachment-only first message is allowed — the references become its body). A remote
    /// project needs text: attachments can't ride the mesh start verb, so they must never be
    /// the thing that lights the send button.
    private var canStart: Bool {
        guard routedRouteAvailable else { return false }
        let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        // An ephemeral chat has no project and no attachments — text is the only requirement.
        if ephemeralMode { return hasText }
        // A project chat needs a resolved project selection (the send button would otherwise start
        // nothing); a local one may also start attachment-only, a remote one needs text.
        guard selectedSummary != nil else { return false }
        return isRemoteProject ? hasText : hasText || !attachments.isEmpty
    }

    /// Whether the ⬇︎ button has anything to drop into the stash — typed text, or staged
    /// attachments on their own (which the stash carries along with the prompt).
    private var hasStashableDraft: Bool {
        !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
    }

    /// Set the typed prompt (and anything staged with it) aside and clear the composer, tagged
    /// with the project it was written for so the stash still reads sensibly days later.
    private func stashDraft() {
        let origin = ephemeralMode ? "Scratch" : (selectedSummary?.name ?? "New chat")
        guard PromptStash.shared.stash(
            draft, attachments: attachments, origin: origin, settings: stashedSettings)
        else { return }
        draft = ""
        attachments = []
    }

    /// Whether a base branch and worktree choice mean anything for the current selection —
    /// the same gate the branch picker and Worktree toggle are shown behind. Under nvrsion or
    /// on a peer Mac's project they're the owner's affair, so they're not worth stashing.
    private var branchChoiceApplies: Bool {
        !ephemeralMode && !isRemoteProject && selectedProject?.nvrsionActive != true
    }

    /// The composer's current setup, captured alongside the prompt. Records the *routing*
    /// level rather than the model it happens to resolve to while the Intelligence slider is
    /// driving — routing exists to pick the model, and pinning one on a pull would quietly
    /// switch this chat to manual. Manual model/effort picks are recorded when they're what's
    /// actually in force.
    private var stashedSettings: StashedComposerSettings {
        StashedComposerSettings(
            projectID: ephemeralMode ? nil : selectedSummary?.id.rawValue,
            projectName: ephemeralMode ? nil : selectedSummary?.name,
            branch: branchChoiceApplies ? effectiveBranch : nil,
            useWorktree: branchChoiceApplies ? useWorktree : nil,
            intelligence: intelligenceRoutingActive ? intelligenceLevel : nil,
            orchestra: orchestraSelected ? true : nil,
            model: intelligenceRoutingActive ? nil : effectiveModel,
            effort: intelligenceRoutingActive || orchestraSelected ? nil : effectiveEffort,
            auto: effectiveAuto,
            ship: ephemeralMode || isRemoteProject ? nil : effectiveShip,
            runOnMesh: showsMeshSelector ? runOnMesh : nil,
            ephemeral: ephemeralMode ? true : nil)
    }

    /// Load a prompt pulled out of the stash back into this composer, with the settings it was
    /// written under. Its media comes back as staged chips — except where this composer can't
    /// stage any (a remote project has no local worktree to materialize them into, an ephemeral
    /// chat no project at all), where the text still lands and the files are left out.
    private func pull(_ prompt: StashedPrompt) {
        // Settings first: they decide whether this is a project or scratch chat, and so
        // whether the attachments below can be staged at all.
        if let settings = prompt.settings { apply(settings) }
        appendPulledPrompt(prompt.text, to: &draft)
        guard !isRemoteProject && !ephemeralMode else { return }
        attachments.append(contentsOf: prompt.attachments.map(\.composerAttachment))
    }

    /// Restore a stashed composer setup. The project is only retargeted when the stashed one
    /// still resolves in the picker — a prompt must never be silently pointed at a *different*
    /// repo because its own is gone or its Mac is offline; the rest of the setup still applies.
    private func apply(_ settings: StashedComposerSettings) {
        if let ephemeral = settings.ephemeral { ephemeralMode = ephemeral }
        var projectChanged = false
        if let id = settings.projectID,
           store.sidebarProjects.contains(where: { $0.id.rawValue == id }) {
            projectChanged = id != selectedSummary?.id.rawValue
            lastProjectID = id
        }
        // A branch name only travels with its project: keep it if the picker just moved (the
        // reload validates it against the new project's branches) or if it's in the list we
        // already have, and otherwise leave the project's default alone.
        if let branch = settings.branch, projectChanged || branches.contains(branch) {
            selectedBranch = branch
        }
        if let worktree = settings.useWorktree { useWorktree = worktree }
        if let level = settings.intelligence {
            // Routing was in force, so clear any manual pins that would suspend it.
            intelligenceLevel = level
            modelOverride = nil
            effortOverride = nil
        }
        if settings.orchestra == true {
            intelligenceLevel = .max
            effortOverride = ModelCatalog.orchestraEffort
        }
        if let model = settings.model { modelOverride = model }
        if let effort = settings.effort { effortOverride = effort }
        if let auto = settings.auto { autoOverride = auto }
        if let ship = settings.ship { shipOverride = ship }
        if let mesh = settings.runOnMesh { runOnMesh = mesh }
    }

    /// Start the chat. `background` (⌘⇧Return, or the ⌘N button default) fires it off without
    /// navigating; the default (⌘Return) follows into the new session.
    private func start(background: Bool) {
        guard routedRouteAvailable else {
            store.lastError = routedResolution.reason
            return
        }
        let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines)
        // Ephemeral, out-of-project (Scratch) chat: no project/branch/worktree/ship — it runs the
        // full agent in a disposable scratch dir and opens in its own popup window (never the main
        // detail pane, whose sidebar has no reserved project to reveal). `background` is irrelevant
        // here; the popup window is the destination either way.
        if ephemeralMode {
            guard !trimmed.isEmpty else { return }
            draft = ""
            let text = SingularityPreparation.prepare(trimmed)
            let auto = effectiveAuto
            Task {
                let picked = await resolveStartSelection(for: trimmed)
                if let id = await store.startEphemeralChat(
                    message: text, model: picked.model, effort: picked.effort,
                    routing: picked.routing, auto: auto) {
                    EphemeralChatWindowController.shared.open(sessionID: id, store: store)
                }
            }
            if floating { dismiss() }
            return
        }
        // A project on a peer Mac (mesh session sync): start the chat on its owner over the
        // mesh — the same wire verb a phone's composer uses. The local-only affordances
        // (branch, worktree, attachments, ship) are hidden for a remote selection; the new
        // chat appears in the sidebar through the owner's broadcasts. Attachments staged
        // before the picker moved to a remote project stay staged (they can't ride the wire
        // and must not be silently thrown away) — the user is told, and they're still there
        // if the picker returns to a local project.
        if isRemoteProject {
            guard let summary = selectedSummary, !trimmed.isEmpty else { return }
            draft = ""
            if !attachments.isEmpty {
                store.lastError = "Attachments can't be sent to a project on another Mac yet — "
                    + "the chat started without them."
            }
            let text = SingularityPreparation.prepare(trimmed)
            let auto = effectiveAuto
            Task {
                // The wire start carries only the concrete strings; the routing note stays
                // local (the owner records its own if it routes).
                let picked = await resolveStartSelection(for: trimmed)
                await store.startRemoteChat(
                    in: summary, message: text, model: picked.model, effort: picked.effort,
                    auto: auto)
            }
            if floating { dismiss() }
            return
        }
        // Mesh destination: hand the start to the dispatcher, which ranks eligible hosts and
        // routes with an acknowledged, idempotent start — and, when nothing is live but the
        // Covalence Cloud runner is enabled, auto-provisions one and lands the chat once it joins.
        // Attachments can't ride a dispatch (same wire limits as a remote start) — say so, keep
        // them staged.
        if runOnMesh, let project = selectedProject, store.meshDispatchPossible(for: project) {
            guard !trimmed.isEmpty else { return }
            draft = ""
            if !attachments.isEmpty {
                store.lastError = "Attachments can't ride a mesh dispatch yet — the chat started without them."
            }
            let text = SingularityPreparation.prepare(trimmed)
            let auto = effectiveAuto
            Task {
                let picked = await resolveStartSelection(for: trimmed)
                await store.dispatchChatToMesh(
                    project: project, message: text, model: picked.model,
                    effort: picked.effort, auto: auto)
            }
            if floating { dismiss() }
            return
        }
        guard let project = selectedProject, !trimmed.isEmpty || !attachments.isEmpty else { return }
        // Hand the staged attachments to the start Task and clear the composer immediately.
        let staged = attachments
        draft = ""
        attachments = []
        let text = SingularityPreparation.prepare(trimmed)
        let base = GitRef(effectiveBranch)
        let worktree = useWorktree
        let auto = effectiveAuto
        let ship = effectiveShip
        // The inline home bar always reveals the new chat; the floating panel follows into the new
        // session on ⌘Return and stays put (background) on ⌘⇧Return.
        let reveal = !floating || !background
        Task {
            // Read each attachment's bytes off the main thread — media can be large.
            let pending = await Task.detached { staged.compactMap { try? $0.pending() } }.value
            let picked = await resolveStartSelection(for: trimmed)
            await store.startChat(
                in: project, message: text, model: picked.model, effort: picked.effort,
                routing: picked.routing,
                base: base, useWorktree: worktree, auto: auto, autoShip: ship, attachments: pending,
                reveal: reveal)
        }
        // Close the floating panel once started. In reveal mode startChat opens the new chat so
        // it's revealed underneath; in background mode it stays on the current view.
        if floating { dismiss() }
    }
}

/// Floating ⌘N / ⌘⇧N presentation of the new-chat composer. Owns a transient draft (so a
/// quick-launch message never bleeds into the home bar's persisted draft) and hands
/// `NewChatComposer` the `floating` flag that adds its titled header and self-dismiss.
struct NewChatComposerSheet: View {
    @Environment(\.dismiss) private var dismiss
    let request: NewChatComposerRequest
    @State private var draft = ""

    var body: some View {
        NewChatComposer(
            draft: $draft, floating: true,
            background: request.background,
            initialEphemeral: request.ephemeral)
            // The panel keeps its own rounded card chrome; the sheet shrink-wraps it into a
            // compact popover bar (it is not meant to fill the window). The card is backed by
            // the system Liquid Glass material so it samples and refracts the window behind it.
            .glassEffect(.regular, in: .rect(cornerRadius: 16))
            // The glass is only a material — it renders but doesn't hit-test — so without a
            // solid shape of its own, a click in the empty space *between* the panel's controls
            // would fall through to the dismiss backdrop below and close the composer. Give the
            // panel a hit shape over its whole rounded rect so those interior clicks are absorbed;
            // only the surrounding space (outside this shape) still reaches the backdrop.
            .contentShape(.rect(cornerRadius: 16))
            // A click in the empty space around the panel dismisses it — the click-outside-to-close
            // feel of a popover. The panel's hit shape above intercepts clicks within its bounds,
            // so only the surrounding space dismisses.
            .background {
                Rectangle().fill(.black.opacity(0.001))
                    .contentShape(Rectangle())
                    .onTapGesture { dismiss() }
            }
            // Drop the sheet's opaque material so the backdrop (and the window behind it) shows
            // around the floating panel instead of a full-window sheet fill.
            .presentationBackground(.clear)
    }
}

/// The SF Symbol for the Mac this app runs on, used by the composer's "Here" (run locally)
/// destination so it reads as *this* machine rather than a generic box. Resolved once and cached —
/// the hardware can't change while the app is running.
///
/// Laptops are detected by the presence of an internal battery, which is reliable across every
/// generation. Desktops are told apart by `hw.model`; Apple's post-2022 "MacN,M" identifiers no
/// longer name the family, so the distinctive desktops (mini/Studio/Pro) are matched against a
/// small table of known boards, and anything unrecognized — an iMac, or a future model — falls
/// back to the generic desktop glyph.
enum LocalMachine {
    static let symbolName: String = resolveSymbol()

    private static func resolveSymbol() -> String {
        if hasInternalBattery { return "macbook" }
        let model = hardwareModel
        if model.hasPrefix("Macmini") || miniBoards.contains(model) { return "macmini.fill" }
        if studioBoards.contains(model) { return "macstudio.fill" }
        if model.hasPrefix("MacPro") || proBoards.contains(model) { return "macpro.gen3.fill" }
        // iMac (its glyph) and any unrecognized desktop share the generic desktop symbol.
        return "desktopcomputer"
    }

    // Apple Silicon desktop board identifiers, grouped by form factor. Laptops never reach this
    // table — the battery check catches them first.
    private static let miniBoards: Set<String> = ["Mac14,3", "Mac14,12", "Mac16,10", "Mac16,11"]
    private static let studioBoards: Set<String> = [
        "Mac13,1", "Mac13,2", "Mac14,13", "Mac14,14", "Mac15,14", "Mac16,9",
    ]
    private static let proBoards: Set<String> = ["Mac14,8"]

    private static var hardwareModel: String {
        var size = 0
        guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { return "" }
        var buffer = [UInt8](repeating: 0, count: size)
        guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return "" }
        // sysctl writes a null-terminated C string; decode up to (not including) the terminator.
        return String(decoding: buffer.prefix { $0 != 0 }, as: UTF8.self)
    }

    private static var hasInternalBattery: Bool {
        guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
              let sources = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as? [CFTypeRef]
        else { return false }
        return sources.contains { source in
            guard let desc = IOPSGetPowerSourceDescription(snapshot, source)?
                .takeUnretainedValue() as? [String: Any],
                let type = desc[kIOPSTypeKey] as? String
            else { return false }
            return type == kIOPSInternalBatteryType
        }
    }
}
