import SwiftUI
import AppKit
import UniformTypeIdentifiers
import NucleicCore
import NucleicProtocol

/// App settings. A source-list sidebar groups the panes into sections — Interface, Agents & AI,
/// Environments, Connectivity — so the panel stays legible and keeps scaling as areas mature. It
/// replaces the original horizontal tab bar, which had run out of room at nine tabs (and dated from
/// when there were only a few). Each pane is a self-contained `Form`; the sidebar just picks which
/// one the detail column shows, and the choice is remembered across launches.
struct SettingsView: View {
    @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
    @AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
    /// Persists the last-open pane so reopening Settings returns you where you left off.
    @AppStorage("nucleic.settings.selectedPane") private var storedPane = SettingsPane.general.rawValue

    /// Starts unset so the first `onAppear` assignment restores `storedPane` — resolving it inline in
    /// the initializer would fight the sidebar's selection binding on first render.
    @State private var selection: SettingsPane?

    /// Pins the sidebar open: sets `canCollapse = false` on the AppKit split-view item so the column
    /// never collapses on its own when the window narrows (see `SidebarColumnController`). Paired with
    /// `.toolbar(removing: .sidebarToggle)` below, which drops the manual collapse button — together
    /// they make the sidebar impossible to hide.
    @State private var sidebarColumn = SidebarColumnController()

    private var appearance: AppAppearance { AppAppearance(rawValue: appearanceRaw) ?? .dark }
    private var textSize: AppTextSize { AppTextSize(rawValue: textSizeRaw) ?? .medium }

    var body: some View {
        NavigationSplitView {
            List(selection: $selection) {
                // Emit each Section explicitly rather than through a `ForEach` over the groups:
                // on macOS a sidebar `List` whose top-level `ForEach` yields `Section`s drops
                // alternating sections, so the odd groups (Agents & AI, Connectivity) rendered as
                // empty headers. Spelling out the sections sidesteps that; adding a future group is
                // still a one-liner here plus a case in `SettingsGroup`.
                sidebarSection(.interface)
                sidebarSection(.agents)
                sidebarSection(.environments)
                sidebarSection(.connectivity)
            }
            // Fixed sidebar width: 1.3× the former 291pt minimum (≈378) — wide enough to
            // fit every tab name without truncating to "…". Equal min/ideal/max sets the
            // SwiftUI hint; the hard pin (non-draggable divider) is enforced in AppKit via
            // SidebarColumnController.fixedWidth.
            .navigationSplitViewColumnWidth(
                min: SidebarColumnController.fixedWidth,
                ideal: SidebarColumnController.fixedWidth,
                max: SidebarColumnController.fixedWidth)
            // Settings should always show its sidebar: drop the manual collapse control, and pin the
            // AppKit split-view item open so a narrow window can't auto-collapse it either.
            .toolbar(removing: .sidebarToggle)
            .background { SidebarColumnConfigurator(controller: sidebarColumn) }
        } detail: {
            detailPane
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                // Match the main window's detail surface. Each pane is a grouped `Form`, whose
                // default scroll background is the system window gray (a warmer, lighter tone than
                // our theme). Hide that and paint the app's own `AppTheme.background` so the Settings
                // pane reads identically to the normal window's detail column (RootView paints the
                // same fill there).
                .scrollContentBackground(.hidden)
                .background(AppTheme.background)
        }
        .navigationSplitViewStyle(.balanced)
        // Fixed overall width so the settings window can't be resized narrow (which is what
        // drove the sidebar's auto-collapse). 378pt sidebar + ~382pt detail = 760. Height stays
        // flexible.
        .frame(minWidth: 760, idealWidth: 760, maxWidth: 760, minHeight: 560, maxHeight: .infinity)
        .preferredColorScheme(appearance.colorScheme)
        .dynamicTypeSize(textSize.dynamicTypeSize)
        .background(SettingsWindowConfigurator(reapplyToken: (selection ?? .general).rawValue))
        .onAppear { if selection == nil { selection = SettingsPane(rawValue: storedPane) ?? .general } }
        .onChange(of: selection) { _, pane in if let pane { storedPane = pane.rawValue } }
    }

    /// One titled sidebar section listing its group's panes as selectable rows.
    private func sidebarSection(_ group: SettingsGroup) -> some View {
        Section(group.title) {
            ForEach(group.panes) { pane in
                Label(pane.title, systemImage: pane.symbol).tag(pane)
            }
        }
    }

    /// The content column for the current selection. Each case is an existing `Form`-based pane view.
    @ViewBuilder private var detailPane: some View {
        switch selection ?? .general {
        case .general: GeneralSettingsTab()
        case .appearance: AppearanceSettingsTab()
        case .notifications: NotificationSettingsTab()
        case .chats: ChatSettingsTab()
        case .agents: AgentsSettingsTab()
        case .usage: UsageSettingsTab()
        case .intelligence: IntelligenceSettingsTab()
        case .sandbox: SandboxSettingsTab()
        case .virtualMachines: MacVMSettingsTab()
        case .control: ControlSettingsTab()
        case .git: GitSettingsTab()
        case .orbital: RemoteSettingsTab()
        }
    }
}

/// One selectable pane in the Settings sidebar. Raw values are stable (they back the persisted
/// last-open pane) and need not be contiguous, so panes can be reordered or inserted freely.
private enum SettingsPane: Int, Identifiable, CaseIterable, Hashable {
    case general = 0
    case appearance = 1
    case notifications = 2
    case chats = 3
    case agents = 4
    case intelligence = 5
    case sandbox = 6
    case virtualMachines = 7
    case control = 8
    case git = 9
    case orbital = 10
    case usage = 11

    var id: Int { rawValue }

    var title: String {
        switch self {
        case .general: "General"
        case .appearance: "Appearance"
        case .notifications: "Notifications"
        case .chats: "Chats"
        case .agents: "Agents"
        case .intelligence: "Intelligence"
        case .sandbox: "Sandbox"
        case .virtualMachines: "Virtual Machines"
        case .control: "Control"
        case .git: "Git"
        case .orbital: "Orbital"
        case .usage: "Usage"
        }
    }

    var symbol: String {
        switch self {
        case .general: "gearshape"
        case .appearance: "paintbrush"
        case .notifications: "bell.badge"
        case .chats: "bubble.left.and.bubble.right"
        case .agents: "cpu"
        case .intelligence: "sparkles"
        case .sandbox: "shippingbox"
        case .virtualMachines: "desktopcomputer"
        case .control: "lock.shield"
        case .git: "arrow.triangle.branch"
        case .orbital: "network"
        case .usage: "gauge.with.dots.needle.67percent"
        }
    }
}

/// A titled section in the sidebar. Declaration order here is the on-screen order of both the
/// sections and the panes within them.
private enum SettingsGroup: Int, Identifiable, CaseIterable {
    case interface
    case agents
    case environments
    case connectivity

    var id: Int { rawValue }

    var title: String {
        switch self {
        case .interface: "Interface"
        case .agents: "Agents & AI"
        case .environments: "Environments"
        case .connectivity: "Connectivity"
        }
    }

    var panes: [SettingsPane] {
        switch self {
        case .interface: [.general, .appearance, .notifications, .chats]
        case .agents: [.agents, .usage, .intelligence]
        case .environments: [.sandbox, .virtualMachines, .control]
        case .connectivity: [.git, .orbital]
        }
    }
}

/// Pins the Settings window's chrome that SwiftUI's `Settings` scene otherwise
/// manages on its own: a constant "Nucleic Settings" title (the default would
/// track the selected tab), float-above-everything level so the main window can't
/// cover it, and a vertically resizable frame.
private struct SettingsWindowConfigurator: NSViewRepresentable {
    /// Re-runs `updateNSView` whenever the selected tab changes so we can restore
    /// the title after the tab switch overwrites it.
    var reapplyToken: Int?

    func makeNSView(context: Context) -> NSView { NSView() }

    func updateNSView(_ nsView: NSView, context: Context) {
        DispatchQueue.main.async {
            guard let window = nsView.window else { return }
            window.title = "Nucleic Settings"
            window.level = .floating
            window.styleMask.insert(.resizable)
        }
    }
}

/// Appearance: the theme, text size, and color-vision accommodation. Split into its own pane (it
/// used to sit atop the General tab) so the sidebar has a dedicated, discoverable home for how the
/// app looks — the conventional place a mature macOS app keeps these.
private struct AppearanceSettingsTab: View {
    @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
    @AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
    @AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue

    var body: some View {
        Form {
            Section("Appearance") {
                Picker("Theme", selection: $appearanceRaw) {
                    ForEach(AppAppearance.allCases) { Text($0.label).tag($0.rawValue) }
                }
                .pickerStyle(.segmented)
                Picker("Text size", selection: $textSizeRaw) {
                    ForEach(AppTextSize.allCases) { Text($0.label).tag($0.rawValue) }
                }
                Picker("Color vision", selection: $colorVisionRaw) {
                    ForEach(ColorVisionMode.allCases) { Text($0.label).tag($0.rawValue) }
                }
            }
        }
        .formStyle(.grouped)
    }
}

/// Notifications: how a chat calls you back when it finishes or needs you — completion and blocked
/// sounds, the escalating alarm, and Dock bouncing. Gathered from the old General tab into one pane
/// since they're all facets of the same "get my attention" behavior.
private struct NotificationSettingsTab: View {
    @AppStorage(ChatStatusSounds.playOnDoneKey) private var playDoneSound = true
    @AppStorage(ChatStatusSounds.playOnBlockedKey) private var playBlockedSound = true
    @AppStorage(ChatStatusSounds.escalatingAlarmKey) private var escalatingAlarm = false
    @AppStorage(DockBounce.enabledKey) private var bounceDock = true

    var body: some View {
        Form {
            Section("Sounds") {
                Toggle("Play a sound when a chat finishes", isOn: $playDoneSound)

                Toggle("Play a sound when a chat needs you", isOn: $playBlockedSound)

                Toggle("Escalating alarm until you return", isOn: $escalatingAlarm)
                    .onChange(of: escalatingAlarm) { _, isOn in
                        if !isOn { ChatAlarm.shared.setActive(false) }  // silence any alarm in progress
                    }
            }

            Section("Dock") {
                Toggle("Bounce the Dock icon until you return", isOn: $bounceDock)
                    .onChange(of: bounceDock) { _, isOn in
                        if !isOn { DockBounce.shared.setPending(false) }  // stop any bounce in progress
                    }
            }
        }
        .formStyle(.grouped)
    }
}

/// General: app-wide behavior that doesn't belong to a more specific pane — the home streak, power
/// (sleep) management, to-do labeling, diagnostics, privacy, updates, and the version footer.
/// Appearance and notification behaviors now have their own panes, leaving this one focused.
private struct GeneralSettingsTab: View {
    @AppStorage(StreakBadge.showKey) private var showStreak = true
    @AppStorage(SleepBlocker.enabledKey) private var blockSleep = false
    @AppStorage(TriageLabelStyle.encouragingKey) private var encouragingTriageLabels = true
    @AppStorage(SingularityPreparation.enabledKey) private var singularityPreparation = false
    @AppStorage(MoveDiagnostics.loggingEnabledKey) private var smartMoveLogging = false
    @AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
    /// Auto-update controller (shared with the sidebar banner). Drives the "Updates" section's
    /// manual check; its background updates surface in the sidebar, not here.
    @EnvironmentObject private var updater: AppUpdater

    var body: some View {
        Form {
            Section("Home") {
                Toggle("Show streak counter", isOn: $showStreak)
            }

            Section("Power") {
                Toggle("Keep the Mac awake (block sleep)", isOn: $blockSleep)
                    .onChange(of: blockSleep) { _, isOn in
                        SleepBlocker.shared.setEnabled(isOn)  // start/stop the assertion immediately
                    }
                Text("Prevents your Mac from going to sleep while Nucleic is running, so long chats, "
                     + "builds, and agent work aren't interrupted. The display can still sleep.")
                    .settingsCaption()
            }

            Section("To-dos") {
                Toggle("Encouraging to-do labels", isOn: $encouragingTriageLabels)
            }

            Section("Diagnostics") {
                Toggle("Smart Move logging", isOn: $smartMoveLogging)
                Button("Reveal Log in Finder") {
                    let url = MoveDiagnostics.logFileURL
                    if FileManager.default.fileExists(atPath: url.path) {
                        NSWorkspace.shared.activateFileViewerSelecting([url])
                    } else {
                        try? FileManager.default.createDirectory(
                            at: MoveDiagnostics.supportDirectory, withIntermediateDirectories: true)
                        NSWorkspace.shared.activateFileViewerSelecting([MoveDiagnostics.supportDirectory])
                    }
                }
                .controlSize(.small)
            }

            Section("Privacy") {
                Toggle("Share anonymous usage", isOn: $shareAnonymousUsage)
            }

            Section("Singularity preparation") {
                Toggle("Prepare for the singularity", isOn: $singularityPreparation)
            }

            // Manual update check. Background updates surface as an unobtrusive sidebar banner;
            // this is the on-demand check. Shown only for builds wired for auto-update (a packaged
            // canary/beta/rc/stable embeds an appcast feed — dev/local builds keep the updater dormant).
            if updater.isConfigured {
                Section("Updates") {
                    HStack(spacing: 8) {
                        if case .found(let version) = updater.manual {
                            Button("Update Now — version \(version)") { updater.installManualUpdate() }
                                .controlSize(.small)
                        } else {
                            Button("Check for Updates…") { updater.checkForUpdates() }
                                .controlSize(.small)
                                .disabled(!updater.canCheckForUpdates)
                        }
                        updateStatusLabel
                        Spacer()
                    }
                }
            }

            // App identity footer: the running version and the build (git commit) it was
            // compiled from. Sits below the Updates section's "Check for Updates…" button and
            // stays visible even on dev/local builds, where that section is hidden — so the
            // exact build can be read (and copied for a bug report) without leaving Settings.
            HStack {
                Spacer()
                Text(Self.versionFooter)
                    .settingsCaption()
                    .textSelection(.enabled)
                Spacer()
            }
            .listRowBackground(Color.clear)
        }
        .formStyle(.grouped)
    }

    /// "Version 0.1.0 (build a1b2c3d 863)" — the marketing version (`CFBundleShortVersionString`),
    /// the commit the binary was built from, and the monotonic `CFBundleVersion` build number,
    /// all resolved once by `BuildInfo`.
    private static var versionFooter: String {
        let info = BuildInfo.current
        let build = [info.buildLabel, info.buildNumber].compactMap { $0 }.joined(separator: " ")
        return "Version \(info.version ?? "—") (build \(build))"
    }

    /// The text/spinner shown next to the update button, reflecting the last manual check.
    @ViewBuilder private var updateStatusLabel: some View {
        switch updater.manual {
        case .checking:
            ProgressView().controlSize(.small)
        case .upToDate:
            Text("You're up to date").settingsCaption()
        case .downloading(let fraction):
            Text(fraction > 0 ? "Updating… \(Int((fraction * 100).rounded()))%" : "Updating…")
                .settingsCaption()
        case .error(let message):
            Text(message).settingsCaption()
        case .idle, .found:
            EmptyView()
        }
    }
}

/// Sandbox containers: the app-wide container service and per-session sandbox containers (for
/// non-control projects). Nucleic Control settings live in their own tab.
private struct SandboxSettingsTab: View {
    @Environment(AppStore.self) private var store
    @AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
    @AppStorage(ContainerServiceSettings.sandboxByDefaultKey) private var sandboxByDefault = false
    @AppStorage(ContainerServiceSettings.sessionContainerCPUsKey)
    private var sessionCPUs = ContainerServiceSettings.defaultContainerCPUs
    @AppStorage(ContainerServiceSettings.sessionContainerMemoryGiBKey)
    private var sessionMemoryGiB = ContainerServiceSettings.defaultContainerMemoryGiB

    /// A specific reason the in-process container runtime is unavailable on this machine, or `nil`
    /// when supported. `nil` until checked on first appearance; drives the "unsupported" hint.
    @State private var unsupportedReason: String?

    var body: some View {
        Form {
            Section("Sandbox containers") {
                // A Nucleic Control project pins the service on — it can't run without it — so the
                // toggle is forced on and locked while one exists (reconcileContainerService).
                Toggle("Enable container service", isOn: $containerServiceEnabled)
                    .disabled(store.containerServiceLockedOn)
                LearnMoreLink("How agent sandboxing works", url: SupportURL.sandboxes)

                if store.containerServiceLockedOn {
                    HStack(alignment: .firstTextBaseline, spacing: 6) {
                        Image(systemName: "lock.fill")
                            .foregroundStyle(.secondary)
                        Text("Required by your Nucleic Control projects. Remove or "
                             + "convert every Control project to turn it off.")
                            .font(.caption)
                    }
                }

                if let unsupportedReason {
                    HStack(alignment: .firstTextBaseline, spacing: 6) {
                        Image(systemName: "exclamationmark.triangle.fill")
                            .foregroundStyle(.orange)
                        Text("Container sandboxing isn't available. \(unsupportedReason)")
                            .font(.caption)
                    }
                }
            }
            .task { unsupportedReason = ContainerEngine.unsupportedReason }

            Section("New projects") {
                Toggle("Sandbox new projects by default", isOn: $sandboxByDefault)
                    .disabled(!containerServiceEnabled)
            }

            // Per-session sandbox resource controls aren't wired up yet; show them disabled and
            // grayed out so the capability is visible but clearly not yet available.
            Section("Per-Session sandbox (coming soon)") {
                Stepper("CPUs: \(sessionCPUs)", value: $sessionCPUs, in: 1...32)
                Stepper("Memory: \(sessionMemoryGiB) GB", value: $sessionMemoryGiB, in: 1...128)
            }
            .disabled(true)
            .opacity(0.5)
        }
        .formStyle(.grouped)
    }
}

/// The Virtual Machines tab: the macOS-guest VM service (Apple `Virtualization`). Enables the
/// `mac_vm_exec` tool — each agent session gets its own isolated macOS VM for Xcode/simulator work,
/// the per-agent alternative to the shared-host `host_exec` (see docs/MACOS_VM.md). Also drives the
/// one-time golden base-image build and shows the live VMs.
private struct MacVMSettingsTab: View {
    @Environment(AppStore.self) private var store
    @AppStorage(MacVMSettings.serviceEnabledKey) private var serviceEnabled = false
    @AppStorage(MacVMSettings.exposeByDefaultKey) private var exposeByDefault = true
    @AppStorage(MacVMSettings.computerUseByDefaultKey) private var computerUseByDefault = true
    @AppStorage(MacVMSettings.axAgentEnabledKey) private var axAgentEnabled = false
    @AppStorage(MacVMSettings.alwaysOpenGuestDisplayKey) private var alwaysOpenGuestDisplay = false
    @AppStorage(MacVMSettings.restoreImageURLKey) private var restoreImageURL = ""
    @AppStorage(MacVMSettings.restoreImageChoiceKey) private var restoreImageChoice = "macos27-devbeta"
    @AppStorage(MacVMSettings.vmCPUsKey) private var vmCPUs = MacVMSettings.defaultVMCPUs
    @AppStorage(MacVMSettings.vmMemoryGiBKey) private var vmMemoryGiB = MacVMSettings.defaultVMMemoryGiB
    @AppStorage(MacVMSettings.maxConcurrentVMsKey)
    private var maxConcurrent = MacVMSettings.defaultMaxConcurrentVMs

    // Linux VM service (a separate, independently-gated guest OS on the same Virtualization stack).
    @AppStorage(MacVMSettings.linuxServiceEnabledKey) private var linuxServiceEnabled = false
    @AppStorage(MacVMSettings.linuxExposeByDefaultKey) private var linuxExposeByDefault = true
    @AppStorage(MacVMSettings.linuxComputerUseByDefaultKey) private var linuxComputerUseByDefault = true
    @AppStorage(MacVMSettings.linuxKernelURLKey) private var linuxKernelURL = ""
    @AppStorage(MacVMSettings.linuxInitrdURLKey) private var linuxInitrdURL = ""
    @AppStorage(MacVMSettings.linuxRootfsURLKey) private var linuxRootfsURL = ""
    @State private var linuxAdvancedExpanded = false

    @State private var baseProgress: MacVMBaseProgress?
    /// Ordered list of build phases observed so far in the current build, so completed steps stay
    /// on screen as a checklist and the whole thing visibly advances (rather than one lone spinner
    /// that reads as "frozen" during a long indeterminate phase). Reset whenever no build is running.
    @State private var seenBuildPhases: [MacVMBaseProgress.Phase] = []
    @State private var runningVMs: [MacVMEntry] = []
    @State private var baseOSVersion: String?
    @State private var baseStatus: MacVMBaseStatus?
    @State private var buildError: String?
    @State private var building = false
    @State private var deleting = false
    // Linux base build/state (the build-progress bar `baseProgress` is shared — only one base builds
    // at a time — and routed to whichever section started it via `linuxBuilding`).
    @State private var linuxBaseStatus: MacVMBaseStatus?
    @State private var linuxBuildError: String?
    @State private var linuxBuilding = false
    @State private var linuxDeleting = false
    @State private var confirmingLinuxDelete = false
    /// Drives the "Delete base image" destructive confirmation dialog.
    @State private var confirmingBaseDelete = false
    /// Whether the base-build diagnostic monitor window is currently shown.
    @State private var observingBaseVM = false
    /// Whether the "Advanced" power-user toggles under the service section are expanded.
    @State private var serviceAdvancedExpanded = false
    /// Host paths of the user-picked `.app` bundles to bake into the base image's `/Applications`
    /// (Settings → "Included apps"). Loaded from / written back to `MacVMSettings.bundledAppPaths`.
    @State private var bundledApps: [String] = []
    /// Whether an app bundle is currently being dragged over the "Included apps" drop zone.
    @State private var appDropTargeted = false
    /// True while a "Copy to running VMs" push is in flight (disables the button).
    @State private var pushingApps = false
    /// The outcome of the last "Copy to running VMs" push, shown beneath the button.
    @State private var appPushStatus: String?
    /// True while an "Add to base image" injection (boot base → copy → power off) is in flight.
    @State private var addingToBase = false
    /// The outcome of the last "Add to base image" injection, shown beneath the button.
    @State private var baseAddStatus: String?

    private var supported: Bool { store.macVMSupported }

    var body: some View {
        Form {
            Section("macOS virtual machines") {
                Toggle("Enable macOS VM service", isOn: $serviceEnabled)
                    .disabled(!supported)
                if !supported {
                    HStack(alignment: .firstTextBaseline, spacing: 6) {
                        Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
                        Text("Running macOS guests requires Apple silicon.").font(.caption)
                    }
                } else {
                    HStack(alignment: .firstTextBaseline, spacing: 6) {
                        Image(systemName: "internaldrive.fill").foregroundStyle(.orange)
                        Text("Keep at least 64 GB of free disk space before enabling — the base "
                            + "image, restore image, and per-session VM disks add up fast.")
                            .font(.caption)
                    }
                    LearnMoreLink(url: SupportURL.macVMs)
                }
                Toggle("Expose to sandboxed sessions by default", isOn: $exposeByDefault)
                    .disabled(!serviceEnabled || !supported)
                Toggle("Virtual computer use", isOn: $computerUseByDefault)
                    .disabled(!serviceEnabled || !supported)
                    .onChange(of: computerUseByDefault) { _, isOn in
                        // Semantic computer use builds on virtual computer use — turning the base off
                        // turns the dependent off too, so it can't linger enabled on its own.
                        if !isOn { axAgentEnabled = false }
                    }
                // Descriptions stay visible whenever their toggle is — so the user knows what each does
                // before turning it on, not only after.
                Text("Uses virtual drivers to work in the background, without opening a window, "
                    + "capturing your mouse, or locking your keyboard. Requires no additional setup.")
                    .settingsCaption()
                if computerUseByDefault {
                    Toggle("Semantic computer use", isOn: $axAgentEnabled)
                        .disabled(!serviceEnabled || !supported)
                    Text("Improves the agent's understanding of human interfaces. Uses the guest's "
                        + "software systems to read and act on the UI at the element level. May "
                        + "significantly increase token usage.")
                        .settingsCaption()
                }
                LearnMoreLink(url: SupportURL.macVMComputerUse)

                DisclosureGroup("Advanced", isExpanded: $serviceAdvancedExpanded) {
                    Toggle("Always open VM guest display", isOn: $alwaysOpenGuestDisplay)
                        .disabled(!serviceEnabled || !supported)
                    Text("Pops a live guest-screen window for every session macOS VM the instant it "
                        + "boots — whatever launched it (a session tool or computer use) and whichever "
                        + "chat is open, or none. The window closes when the VM stops. Normally the screen "
                        + "is watched on demand (the Control panel's \"Observe\"); this forces it "
                        + "always-on — useful for Nucleic developers, or anyone diagnosing a VM that "
                        + "won't behave. (The base-image build has its own progress view above.)")
                        .settingsCaption()
                }
            }

            Section("Base image") {
                if let baseOSVersion {
                    LabeledContent("Installed guest macOS", value: baseOSVersion)
                }
                if let baseProgress, !linuxBuilding {
                    VStack(alignment: .leading, spacing: 8) {
                        ForEach(seenBuildPhases, id: \.self) { phase in
                            buildStageRow(
                                phase,
                                active: phase == baseProgress.phase && phase != .ready,
                                fraction: baseProgress.fraction,
                                detail: phase == baseProgress.phase ? baseProgress.detail : nil)
                        }
                    }
                    Text("This can take a while — installing and provisioning macOS downloads several "
                        + "gigabytes and reboots the guest a few times. You can leave this and keep working; "
                        + "it runs in the background.")
                        .settingsCaption()
                    Button {
                        Task {
                            observingBaseVM.toggle()
                            await store.setMacVMBaseObserver(visible: observingBaseVM)
                        }
                    } label: {
                        Label(
                            observingBaseVM ? "Hide VM screen" : "Show VM screen",
                            systemImage: observingBaseVM ? "eye.slash" : "eye")
                    }
                    Text("Opens a live window of the base VM during the build — watch (or click into) a "
                        + "build that's stuck, e.g. a first-boot Setup Assistant blocking the automated "
                        + "provisioning.")
                        .settingsCaption()
                } else {
                    Button {
                        Task { await runBaseBuild() }
                    } label: {
                        Label(
                            baseStatus?.installed == true
                                ? "Rebuild / re-provision base image" : "Build base image",
                            systemImage: "square.and.arrow.down.on.square")
                            .padding(.vertical, 4)
                    }
                    .disabled(!supported || building || deleting)
                    LearnMoreLink("How base-image builds work", url: SupportURL.macVMBaseImage)
                    if let baseStatus, baseStatus.installed {
                        baseStatusView(baseStatus)
                        Button(role: .destructive) {
                            confirmingBaseDelete = true
                        } label: {
                            Label("Delete base image…", systemImage: "trash")
                        }
                        .disabled(!supported || building || deleting)
                        Text("Removes the installed base so the next build reinstalls macOS from "
                            + "scratch — unlike Rebuild, which only re-runs provisioning on the existing "
                            + "install. Re-downloads the ~14 GB restore image.")
                            .settingsCaption()
                    }
                }
                if let buildError {
                    Text(buildError).font(.caption).foregroundStyle(.red)
                }
                Picker("Base macOS version", selection: $restoreImageChoice) {
                    ForEach(Self.ipswCatalog) { choice in
                        Text(choice.label).tag(choice.id)
                    }
                }
                .onChange(of: restoreImageChoice) { _, newValue in
                    applyIPSWChoice(newValue)
                }
            }
            .disabled(!supported)
            .confirmationDialog(
                "Delete the installed base image?", isPresented: $confirmingBaseDelete,
                titleVisibility: .visible
            ) {
                Button("Delete & reinstall (keep download)", role: .destructive) {
                    Task { await deleteBaseImage(includingRestoreImages: false) }
                }
                Button("Delete & re-download everything", role: .destructive) {
                    Task { await deleteBaseImage(includingRestoreImages: true) }
                }
                Button("Cancel", role: .cancel) {}
            } message: {
                Text("Removes the installed macOS base so the next build reinstalls from scratch — "
                    + "unlike Rebuild, which only re-provisions the existing install. Keep the cached "
                    + "~14 GB restore image for a faster reinstall, or re-download it too for a fully "
                    + "clean slate. Running sessions and any configured prebuilt base are unaffected.")
            }

            Section("Included apps") {
                VStack(alignment: .leading, spacing: 10) {
                    Text("Drag in — or browse for — “.app” bundles to bake into the base image's "
                        + "Applications folder, so every session VM has them. They're included when you "
                        + "build the base — or add them to an existing base (or running VMs) with the "
                        + "buttons below, no full rebuild needed.")
                        .settingsCaption()

                    if bundledApps.isEmpty {
                        HStack(spacing: 6) {
                            Image(systemName: "square.and.arrow.down")
                                .foregroundStyle(.secondary)
                            Text("Drop “.app” bundles here, or use “Add app…”.")
                                .font(.caption).foregroundStyle(.secondary)
                        }
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding(.vertical, 6)
                    } else {
                        ForEach(bundledApps, id: \.self) { path in
                            includedAppRow(path)
                        }
                    }

                    HStack {
                        Button {
                            chooseApps()
                        } label: {
                            Label("Add app…", systemImage: "plus")
                        }
                        if !bundledApps.isEmpty {
                            Spacer()
                            Text("^[\(bundledApps.count) app](inflect: true)")
                                .font(.caption).foregroundStyle(.secondary)
                        }
                    }
                }
                .padding(8)
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(
                    RoundedRectangle(cornerRadius: 8)
                        .strokeBorder(
                            appDropTargeted ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear),
                            style: StrokeStyle(lineWidth: 2, dash: [5]))
                )
                .dropDestination(for: URL.self) { urls, _ in
                    addApps(urls)
                } isTargeted: { appDropTargeted = $0 }

                if let baseStatus, baseStatus.installed {
                    Button {
                        Task { await addAppsToBase() }
                    } label: {
                        HStack(spacing: 6) {
                            if addingToBase { ProgressView().controlSize(.small) }
                            Label("Add to base image", systemImage: "square.and.arrow.down.on.square")
                        }
                    }
                    .disabled(addingToBase || building || deleting || bundledApps.isEmpty)
                    Text("Adds the apps above to the existing base image without a full rebuild: boots "
                        + "the base briefly, copies them into its Applications folder, and powers it "
                        + "off. New session VMs then include them. (Takes a minute or two.)")
                        .settingsCaption()
                    if let baseAddStatus {
                        Text(baseAddStatus).font(.caption).foregroundStyle(.secondary)
                    }
                }

                if !runningVMs.isEmpty {
                    Button {
                        Task { await pushAppsToRunningVMs() }
                    } label: {
                        HStack(spacing: 6) {
                            if pushingApps { ProgressView().controlSize(.small) }
                            Label(
                                "Copy to \(runningVMs.count) running VM\(runningVMs.count == 1 ? "" : "s")",
                                systemImage: "arrow.down.app")
                        }
                    }
                    .disabled(pushingApps || bundledApps.isEmpty)
                    Text("Also streams the apps into each already-running VM's Applications folder over "
                        + "the guest agent (no restart). Existing copies are replaced.")
                        .settingsCaption()
                    if let appPushStatus {
                        Text(appPushStatus).font(.caption).foregroundStyle(.secondary)
                    }
                }
            }
            .disabled(!serviceEnabled || !supported)

            Section("Per-session VM resources") {
                Stepper("CPUs: \(vmCPUs)", value: $vmCPUs, in: 1...32)
                Stepper("Memory: \(vmMemoryGiB) GB", value: $vmMemoryGiB, in: 2...128)
                Stepper("Max concurrent VMs: \(maxConcurrent)", value: $maxConcurrent, in: 1...8)
                Text("macOS caps how many macOS guests run at once (2 on recent releases); extra "
                    + "agents queue for a VM rather than failing.")
                    .settingsCaption()
            }
            .disabled(!serviceEnabled || !supported)

            if !runningVMs.isEmpty {
                Section("Running now") {
                    ForEach(runningVMs, id: \.name) { vm in
                        LabeledContent(vm.name, value: vm.ipAddress ?? "booting…")
                    }
                }
            }
        }
        .formStyle(.grouped)
        .task { await poll() }
        .onAppear {
            reconcileBaseImageChoice()
            bundledApps = MacVMSettings.bundledAppPaths
        }
    }

    // MARK: - Included apps (baked into the base image's /Applications)

    /// One row in the "Included apps" list: the app's Finder icon, its name, its full path, and a
    /// remove control.
    @ViewBuilder
    private func includedAppRow(_ path: String) -> some View {
        HStack(spacing: 10) {
            Image(nsImage: NSWorkspace.shared.icon(forFile: path))
                .resizable()
                .frame(width: 22, height: 22)
            VStack(alignment: .leading, spacing: 1) {
                Text(Self.appDisplayName(path)).font(.callout)
                Text(path)
                    .font(.caption2).foregroundStyle(.secondary)
                    .lineLimit(1).truncationMode(.middle)
            }
            Spacer()
            Button {
                removeApp(path)
            } label: {
                Image(systemName: "minus.circle.fill")
                    .foregroundStyle(.secondary)
            }
            .buttonStyle(.borderless)
            .help("Remove")
        }
    }

    /// The user-facing app name (bundle name minus the `.app` extension).
    private static func appDisplayName(_ path: String) -> String {
        ((path as NSString).lastPathComponent as NSString).deletingPathExtension
    }

    /// Add dragged/browsed URLs, keeping only existing `.app` bundles and de-duplicating against the
    /// current list. Returns whether anything was accepted (also the `.dropDestination` result).
    @discardableResult
    private func addApps(_ urls: [URL]) -> Bool {
        let fm = FileManager.default
        let apps = urls.filter {
            $0.pathExtension.lowercased() == "app" && fm.fileExists(atPath: $0.path)
        }
        guard !apps.isEmpty else { return false }
        var updated = bundledApps
        for url in apps where !updated.contains(url.path) {
            updated.append(url.path)
        }
        guard updated != bundledApps else { return false }
        bundledApps = updated
        MacVMSettings.setBundledAppPaths(bundledApps)
        return true
    }

    /// Drop an app from the include list (does not touch the source bundle on disk).
    private func removeApp(_ path: String) {
        bundledApps.removeAll { $0 == path }
        MacVMSettings.setBundledAppPaths(bundledApps)
    }

    /// Copy the configured apps into every running VM over vsock (no rebuild), surfacing the outcome.
    private func pushAppsToRunningVMs() async {
        pushingApps = true
        appPushStatus = nil
        defer { pushingApps = false }
        appPushStatus = await store.pushIncludedAppsToRunningMacVMs()
    }

    /// Inject the configured apps into the existing base image (boot → copy → power off), so new VMs
    /// include them without a full rebuild. Refreshes base status afterward.
    private func addAppsToBase() async {
        addingToBase = true
        baseAddStatus = nil
        defer { addingToBase = false }
        baseAddStatus = await store.addIncludedAppsToMacVMBase()
        baseStatus = await store.macVMBaseStatus()
    }

    /// Browse for one or more `.app` bundles to include, defaulting to `/Applications`.
    private func chooseApps() {
        let panel = NSOpenPanel()
        panel.canChooseFiles = true
        panel.canChooseDirectories = false
        panel.allowsMultipleSelection = true
        panel.allowedContentTypes = [.application]
        panel.treatsFilePackagesAsDirectories = false
        panel.directoryURL = URL(fileURLWithPath: "/Applications")
        panel.message = "Choose “.app” bundles to include in the macOS VM base image"
        panel.prompt = "Add"
        if panel.runModal() == .OK { addApps(panel.urls) }
    }

    // MARK: - Guest-image (.ipsw) catalog

    /// One selectable restore image in the hardcoded catalog. `url` is the remote `.ipsw`; an empty
    /// `url` is a placeholder (image not published yet) that falls back to "latest supported".
    private struct IPSWChoice: Identifiable, Hashable {
        let id: String
        let label: String
        let url: String
    }

    /// Hardcoded list of guest macOS restore images offered in the picker. Selecting one writes its
    /// `url` into the `restoreImageURL` setting (empty → clears it, i.e. latest supported).
    private static let ipswCatalog: [IPSWChoice] = [
        IPSWChoice(
            id: "macos27-devbeta",
            label: "macOS 27 Developer Beta",
            url: "https://updates.cdn-apple.com/2026SummerSeed/fullrestores/140-36017/"
                + "0F8D2136-7924-4F3E-A9AA-99722A9EFB39/UniversalMac_27.0_26A5378j_Restore.ipsw"),
        IPSWChoice(id: "macos27-publicbeta", label: "macOS 27 Public Beta", url: ""),
    ]

    /// Look up a catalog choice by its picker id.
    private static func ipswChoice(for id: String) -> IPSWChoice? {
        ipswCatalog.first { $0.id == id }
    }

    /// Apply a picker selection: point the restore-image URL at the chosen catalog image (empty for a
    /// not-yet-published placeholder, which the engine treats as "latest supported").
    private func applyIPSWChoice(_ id: String) {
        restoreImageURL = Self.ipswChoice(for: id)?.url ?? ""
    }

    /// Resolve the picker selection on appearance: an unknown/legacy value falls back to the default
    /// image and the chosen image's URL is kept in sync. Also clears any stale manual restore-source
    /// overrides left by the removed "Advanced" panel so they can't silently override the picker.
    private func reconcileBaseImageChoice() {
        UserDefaults.standard.removeObject(forKey: MacVMSettings.basePrebuiltPathKey)
        UserDefaults.standard.removeObject(forKey: MacVMSettings.restoreImagePathKey)
        if Self.ipswChoice(for: restoreImageChoice) == nil { restoreImageChoice = "macos27-devbeta" }
        applyIPSWChoice(restoreImageChoice)
    }

    /// Post-build status: exec-ready vs. fully computer-use-ready. Semantic AX ships in the base
    /// image and its grants are written automatically during the build, so once the base is
    /// provisioned everything — builds, computer use, and semantic AX — is ready.
    @ViewBuilder
    private func baseStatusView(_ status: MacVMBaseStatus) -> some View {
        // Computer use works host-side on any installed base; a fully provisioned base adds the toolchain.
        if status.provisioned {
            Label("Ready — builds + computer use + semantic AX.", systemImage: "checkmark.seal.fill")
                .font(.caption).foregroundStyle(.green)
        } else {
            Label("Computer use ready. Toolchain provisioning incomplete — rebuild.",
                systemImage: "checkmark.circle").font(.caption)
        }
    }

    private func runBaseBuild() async {
        building = true
        buildError = nil
        defer { building = false }
        do {
            try await store.buildMacVMBaseImage(localRestoreImagePath: nil)
        } catch {
            buildError = "\(error)"
        }
    }

    /// Delete the installed base (and cached restore image) so the next build reinstalls macOS from
    /// scratch. Refreshes status immediately so the panel flips back to "Build base image".
    private func deleteBaseImage(includingRestoreImages: Bool) async {
        deleting = true
        buildError = nil
        defer { deleting = false }
        do {
            try await store.deleteMacVMBaseImage(includingRestoreImages: includingRestoreImages)
            baseStatus = await store.macVMBaseStatus()
            baseOSVersion = await store.macVMBaseOSVersion()
        } catch {
            buildError = "\(error)"
        }
    }

    /// Poll base-build progress + the live VM list while the tab is open.
    private func poll() async {
        while !Task.isCancelled {
            let progress = await store.macVMBaseProgress()
            recordBuildPhase(progress)
            baseProgress = progress
            // The build ended (surface detached, monitor auto-hidden) — reset the toggle to match.
            if progress == nil, observingBaseVM {
                observingBaseVM = false
                await store.setMacVMBaseObserver(visible: false)
            }
            runningVMs = await store.runningMacVMs()
            baseOSVersion = await store.macVMBaseOSVersion()
            baseStatus = await store.macVMBaseStatus()
            try? await Task.sleep(for: .seconds(2))
        }
    }

    /// Grow (or reset) the observed-phase checklist. Phases are reported one at a time and advance
    /// monotonically, so appending each newly-seen phase preserves build order; a `nil` progress
    /// (no build running) clears the list for the next run.
    private func recordBuildPhase(_ progress: MacVMBaseProgress?) {
        guard let progress else {
            if !seenBuildPhases.isEmpty { seenBuildPhases = [] }
            return
        }
        if !seenBuildPhases.contains(progress.phase) {
            seenBuildPhases.append(progress.phase)
        }
    }

    /// One row in the build checklist: a green check for a completed stage, or the stage label plus
    /// an animated linear bar (indeterminate, or determinate when the phase reports a fraction) for
    /// the stage in progress.
    @ViewBuilder
    private func buildStageRow(
        _ phase: MacVMBaseProgress.Phase, active: Bool, fraction: Double?, detail: String? = nil
    ) -> some View {
        HStack(alignment: .top, spacing: 8) {
            Image(systemName: active ? "circle.fill" : "checkmark.circle.fill")
                .font(.caption2)
                .foregroundStyle(active ? AnyShapeStyle(.tint) : AnyShapeStyle(.green))
                .frame(width: 14)
                .padding(.top, 1)
            VStack(alignment: .leading, spacing: 4) {
                Text(MacVMBaseProgress(phase: phase).label)
                    .font(.caption)
                    .fontWeight(active ? .semibold : .regular)
                    .foregroundStyle(active ? .primary : .secondary)
                if active {
                    if let fraction {
                        ProgressView(value: fraction).progressViewStyle(.linear)
                    } else {
                        ProgressView().progressViewStyle(.linear)
                    }
                    if let detail, !detail.isEmpty {
                        Text(detail)
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                            .lineLimit(2)
                            .textSelection(.enabled)
                    }
                }
            }
        }
    }
}

/// The Git tab: Nucleic's Managed Git credential and where it's used. The credential is always
/// used for Nucleic's own host-side git (cloning a project, pushing a branch, opening a PR) and is
/// optionally made available to sandboxed sessions. The SSH config section re-supplies any
/// connectivity Managed Git's hermetic ssh would otherwise miss.
private struct GitSettingsTab: View {
    var body: some View {
        Form {
            GitHubAccessSection()
            ManagedGitSSHConfigSection()
        }
        .formStyle(.grouped)
    }
}

/// Extra OpenSSH config for Managed Git's connections to GitHub. Managed Git deliberately ignores
/// the user's real `~/.ssh/config` (to stay off their personal keys and ssh-agent), which also drops
/// any connectivity directives there — so this section lets the user re-supply just the connectivity
/// they need (a custom `Hostname`/`Port`, a `ProxyCommand`…). Identity/agent directives are ignored:
/// Managed Git always uses its own key. Applies to host and sandbox ssh alike, and only in the SSH /
/// Nucleic-managed-key modes (it's inert for token/none).
private struct ManagedGitSSHConfigSection: View {
    @AppStorage(GitHubCredentialSettings.sshConfigKey) private var sshConfig = ""

    var body: some View {
        Section("SSH config") {
            TextEditor(text: $sshConfig)
                .font(.system(.caption, design: .monospaced))
                .frame(minHeight: 80)
                .overlay(RoundedRectangle(cornerRadius: 4).stroke(.quaternary))
        }
    }
}

/// The app-level Managed Git credential. It's always used for Nucleic's own host-side `git`
/// (cloning a project, pushing a branch, opening a PR) — pinned via SSH `IdentitiesOnly` so the
/// host never falls back to the user's personal `~/.ssh` keys — and is optionally injected into
/// sandboxed sessions ("Make available in sandbox"; Control sessions always get it). The secret
/// lives in the login Keychain (via `GitHubCredentialStore`), never in UserDefaults; only the mode
/// and flags are plain preferences. The Nucleic-managed key never surfaces its private half — only
/// its public key can be copied or exported.
private struct GitHubAccessSection: View {
    @Environment(AppStore.self) private var store

    @AppStorage(GitHubCredentialSettings.authModeKey) private var modeRaw = GitHubAuthMode.none.rawValue
    @AppStorage(GitHubCredentialSettings.signCommitsKey) private var signCommits = false
    @AppStorage(GitHubCredentialSettings.ghForceSSHKey) private var ghForceSSH = false
    @AppStorage(GitHubCredentialSettings.gitForceSSHKey) private var gitForceSSH = false
    @AppStorage(GitHubCredentialSettings.availableInSandboxKey) private var availableInSandbox = true

    /// Draft input for the secret being entered; never pre-filled from the Keychain (we don't
    /// read secrets back into the UI). `tokenStored` / `keyStored` reflect whether one is saved.
    @State private var tokenDraft = ""
    @State private var keyDraft = ""
    /// A filesystem path to load a key from (the file-picker fills this; it can also be typed).
    @State private var pathDraft = ""
    /// Passphrase for an encrypted key being saved. Taken verbatim (never trimmed).
    @State private var passphraseDraft = ""
    @State private var tokenStored = false
    @State private var keyStored = false
    @State private var passphraseStored = false
    /// A validation complaint about the pasted/loaded SSH key (not a real private key, or
    /// encrypted-without-a-passphrase).
    @State private var keyWarning: String?
    /// The Nucleic-managed public key, when one has been generated; drives the `.managed` UI.
    @State private var managedPublicKey: String?
    /// A failure from generating the managed key.
    @State private var managedError: String?

    private var mode: GitHubAuthMode { GitHubAuthMode(rawValue: modeRaw) ?? .none }

    /// True when Nucleic Control is in use — either turned on for new projects, or there's already
    /// at least one Control project. Control sessions run in the shared container, which has no other
    /// way to reach GitHub, so the credential is always made available to them; the toggle is then
    /// forced on and locked.
    private var controlActive: Bool {
        ContainerServiceSettings.controlByDefault
            || store.projects.contains { $0.isNucleicControlled }
    }

    var body: some View {
        Section("Managed Git") {
            Picker("Authentication", selection: $modeRaw) {
                Text("None").tag(GitHubAuthMode.none.rawValue)
                Text("Token (HTTPS)").tag(GitHubAuthMode.token.rawValue)
                Text("SSH key").tag(GitHubAuthMode.ssh.rawValue)
                Text("Nucleic-managed key").tag(GitHubAuthMode.managed.rawValue)
            }

            switch mode {
            case .none:
                EmptyView()
            case .token:
                tokenControls
            case .ssh:
                sshKeyEntry
                sshOptions
            case .managed:
                managedKeyEntry
                sshOptions
            }

            // Whether sandboxed sessions also get this credential. Control sessions always do (their
            // shared container can't reach GitHub otherwise), so it's forced on and locked then.
            if controlActive {
                Toggle("Make available in sandbox", isOn: .constant(true))
                    .disabled(true)
            } else {
                Toggle("Make available in sandbox", isOn: $availableInSandbox)
                    .disabled(mode == .none)
            }
        }
        .onAppear {
            refreshStoredFlags()
            managedPublicKey = ManagedSSHKey.publicKey
            // Keep the stored value consistent with the locked-on display, so per-session sandboxes
            // also see it as available while Control is in use.
            if controlActive { availableInSandbox = true }
        }
    }

    // MARK: Token

    @ViewBuilder private var tokenControls: some View {
        tokenEntry()
    }

    /// Save/clear UI for the stored PAT. Backed by the one Keychain token slot, so the same secret
    /// serves token mode (git + gh) and SSH mode (gh API).
    @ViewBuilder private func tokenEntry() -> some View {
        if tokenStored {
            storedRow(label: "Token saved") {
                GitHubCredentialStore.deleteToken()
                refreshStoredFlags()
            }
        } else {
            SecureField("Personal access token", text: $tokenDraft)
            Button("Save token") {
                GitHubCredentialStore.saveToken(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines))
                tokenDraft = ""
                refreshStoredFlags()
            }
            .disabled(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
        }
    }

    // MARK: SSH (user-supplied key)

    @ViewBuilder private var sshKeyEntry: some View {
        if keyStored {
            storedRow(label: passphraseStored ? "SSH key saved (with passphrase)" : "SSH key saved") {
                GitHubCredentialStore.deleteSSHPrivateKey()
                GitHubCredentialStore.deleteSSHPassphrase()
                refreshStoredFlags()
            }
        } else {
            TextEditor(text: $keyDraft)
                .font(.system(.caption, design: .monospaced))
                .frame(minHeight: 90)
                .overlay(RoundedRectangle(cornerRadius: 4).stroke(.quaternary))
            HStack {
                Button("Choose File…") { chooseKeyFile() }
                TextField("or type a key path", text: $pathDraft)
                    .textFieldStyle(.roundedBorder)
                Button("Load") { loadKey(from: pathDraft) }
                    .disabled(pathDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
            }
            SecureField("Passphrase (only if the key is encrypted)", text: $passphraseDraft)
            if let keyWarning {
                Label(keyWarning, systemImage: "exclamationmark.triangle.fill")
                    .font(.caption)
                    .foregroundStyle(.orange)
            }
            Button("Save SSH key") { saveSSHKey() }
                .disabled(keyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
        }
    }

    private func saveSSHKey() {
        let key = keyDraft.trimmingCharacters(in: .whitespacesAndNewlines)
        guard key.contains("PRIVATE KEY") else {
            keyWarning = "That doesn't look like an OpenSSH private key (expected a "
                + "“-----BEGIN … PRIVATE KEY-----” block)."
            return
        }
        // Passphrases can contain meaningful whitespace, so take the field verbatim.
        let passphrase = passphraseDraft
        if key.contains("ENCRYPTED") && passphrase.isEmpty {
            keyWarning = "This key is passphrase-protected. Enter its passphrase below so the "
                + "sandbox can unlock it non-interactively."
            return
        }
        keyWarning = nil
        GitHubCredentialStore.saveSSHPrivateKey(key)
        if passphrase.isEmpty {
            GitHubCredentialStore.deleteSSHPassphrase()
        } else {
            GitHubCredentialStore.saveSSHPassphrase(passphrase)
        }
        keyDraft = ""
        passphraseDraft = ""
        pathDraft = ""
        refreshStoredFlags()
    }

    /// Open a file picker for a private key and load its contents into the editor. `~/.ssh` is
    /// hidden, so show hidden files.
    private func chooseKeyFile() {
        let panel = NSOpenPanel()
        panel.canChooseFiles = true
        panel.canChooseDirectories = false
        panel.allowsMultipleSelection = false
        panel.showsHiddenFiles = true
        panel.message = "Choose an OpenSSH private key"
        if panel.runModal() == .OK, let url = panel.url { loadKey(from: url.path) }
    }

    /// Read a private key from `path` (tilde-expanded) into the editor, where the usual Save flow
    /// validates it. We don't save straight from disk so the user can confirm/add a passphrase first.
    private func loadKey(from path: String) {
        let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        let expanded = (trimmed as NSString).expandingTildeInPath
        do {
            keyDraft = try String(contentsOfFile: expanded, encoding: .utf8)
            pathDraft = ""
            keyWarning = nil
        } catch {
            keyWarning = "Couldn't read a key from \(expanded): \(error.localizedDescription)"
        }
    }

    // MARK: Nucleic-managed key

    @ViewBuilder private var managedKeyEntry: some View {
        if let pub = managedPublicKey {
            Label("Key generated", systemImage: "checkmark.seal.fill")
                .foregroundStyle(.green)
            Text(pub)
                .font(.system(.caption, design: .monospaced))
                .textSelection(.enabled)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(6)
                .background(RoundedRectangle(cornerRadius: 4).fill(.quaternary.opacity(0.4)))
            HStack {
                Button("Copy public key") { copyToPasteboard(pub) }
                Button("Export…") { exportPublicKey(pub) }
                Spacer()
                Button("Regenerate", role: .destructive) { generateManagedKey() }
                Button("Remove", role: .destructive) {
                    ManagedSSHKey.delete()
                    managedPublicKey = nil
                }
            }
        } else {
            Button("Generate Nucleic-managed key") { generateManagedKey() }
            if let managedError {
                Label(managedError, systemImage: "exclamationmark.triangle.fill")
                    .font(.caption)
                    .foregroundStyle(.orange)
            }
        }
    }

    private func generateManagedKey() {
        do {
            managedPublicKey = try ManagedSSHKey.generate()
            managedError = nil
        } catch {
            managedError = "Couldn't generate a key: \(error.localizedDescription)"
        }
    }

    private func exportPublicKey(_ pub: String) {
        let panel = NSSavePanel()
        panel.nameFieldStringValue = "nucleic-managed.pub"
        panel.canCreateDirectories = true
        if panel.runModal() == .OK, let url = panel.url {
            try? (pub + "\n").write(to: url, atomically: true, encoding: .utf8)
        }
    }

    private func copyToPasteboard(_ value: String) {
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(value, forType: .string)
    }

    // MARK: Shared SSH options (apply to both .ssh and .managed)

    @ViewBuilder private var sshOptions: some View {
        Toggle("Sign commits with this SSH key", isOn: $signCommits)

        Toggle("Force gh to use SSH", isOn: $ghForceSSH)

        Toggle("Restrict git to SSH for GitHub", isOn: $gitForceSSH)

        Text("API token for gh (optional)")
            .font(.callout.weight(.medium))
        tokenEntry()
    }

    // MARK: Helpers

    @ViewBuilder private func storedRow(label: String, clear: @escaping () -> Void) -> some View {
        HStack {
            Label(label, systemImage: "checkmark.seal.fill")
                .foregroundStyle(.green)
            Spacer()
            Button("Clear", role: .destructive, action: clear)
        }
    }

    private func refreshStoredFlags() {
        tokenStored = !(GitHubCredentialStore.loadToken() ?? "").isEmpty
        keyStored = !(GitHubCredentialStore.loadSSHPrivateKey() ?? "").isEmpty
        passphraseStored = !(GitHubCredentialStore.loadSSHPassphrase() ?? "").isEmpty
    }
}

/// Nucleic Control: repos Nucleic clones under `~/.nucleic/control/` and manages end-to-end
/// (shared sandbox container, the `git` interceptor, autoship-eligible). Requires the container
/// service (Sandbox tab).
private struct ControlSettingsTab: View {
    @Environment(AppStore.self) private var store

    @AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
    @AppStorage(ContainerServiceSettings.controlByDefaultKey) private var controlByDefault = false
    @AppStorage(ContainerServiceSettings.nvrsionByDefaultKey) private var nvrsionByDefault = false
    @AppStorage(ContainerServiceSettings.controlContainerCPUsKey)
    private var controlCPUs = ContainerServiceSettings.defaultControlContainerCPUs
    @AppStorage(ContainerServiceSettings.controlContainerMemoryGiBKey)
    private var controlMemoryGiB = ContainerServiceSettings.recommendedControlContainerMemoryGiB()
    @AppStorage(ContainerServiceSettings.controlAuthModeKey)
    private var authMode = ControlAuthMode.oauth.rawValue
    @AppStorage(ContainerServiceSettings.splitControlContainersByBackendKey)
    private var splitContainersByBackend = false
    @AppStorage(ContainerServiceSettings.vsockControlPlaneEnabledKey)
    private var vsockControlPlane = true  // default-on as of sandbox image v4 (ships control-bridge.js)
    @AppStorage(ContainerServiceSettings.memoryManagementKey)
    private var memoryManagement = MemoryManagementLevel.conservative.rawValue
    @AppStorage(ContainerServiceSettings.commandTracingEnabledKey)
    private var commandTracing = false
    /// Draft for the API-key entry field (write-only — the stored key is never read back into UI).
    @State private var apiKeyDraft = ""
    /// Whether a key is currently in the Keychain, for the "Key saved" / Clear affordances.
    @State private var keySaved = ControlAPIKeyStore.hasKey

    /// True while a force-recreation is in flight, to disable the button and show progress.
    @State private var recreating = false
    /// True while a memory-reclaiming restart is in flight.
    @State private var restarting = false
    /// True while an on-demand "Reclaim memory now" pass is in flight.
    @State private var reclaiming = false

    /// Upper bound for the memory stepper: this Mac's total physical RAM. `--memory` is a ceiling
    /// the VM grows into, so allocating more than the host has would be meaningless.
    private var maxControlMemoryGiB: Int {
        max(1, Int(ProcessInfo.processInfo.physicalMemory / 1_073_741_824))
    }

    var body: some View {
        Form {
            Section("Nucleic Control") {
                if !containerServiceEnabled {
                    HStack(alignment: .firstTextBaseline, spacing: 6) {
                        Image(systemName: "exclamationmark.triangle.fill")
                            .foregroundStyle(.orange)
                        Text("Turn on the container service in Settings → Sandbox to use Nucleic "
                             + "Control.")
                            .font(.caption)
                    }
                }

                Toggle("Enable Nucleic Control on all projects", isOn: $controlByDefault)
                    .disabled(!containerServiceEnabled)
                LearnMoreLink("What is Nucleic Control?", url: SupportURL.nucleicControl)
            }

            Section {
                if !containerServiceEnabled {
                    HStack(alignment: .firstTextBaseline, spacing: 6) {
                        Image(systemName: "exclamationmark.triangle.fill")
                            .foregroundStyle(.orange)
                        Text("Turn on the container service in Settings → Sandbox to use nvrsion.")
                            .font(.caption)
                    }
                }

                Toggle("Use nvrsion for new Control projects", isOn: $nvrsionByDefault)
                    .disabled(!containerServiceEnabled)
            } header: {
                HStack(spacing: 6) {
                    Text("nvrsion")
                    Text("BETA")
                        .font(.caption2).bold()
                        .padding(.horizontal, 5).padding(.vertical, 1)
                        .background(AppTheme.hairline, in: .capsule)
                }
            }

            Section("Container authentication") {
                Picker("Authenticate Claude with", selection: $authMode) {
                    Text("Claude subscription (OAuth)").tag(ControlAuthMode.oauth.rawValue)
                    Text("Anthropic API key").tag(ControlAuthMode.apiKey.rawValue)
                }
                .disabled(!containerServiceEnabled)

                if authMode == ControlAuthMode.apiKey.rawValue {
                    SecureField("sk-ant-…", text: $apiKeyDraft)
                        .disabled(!containerServiceEnabled)
                    HStack(spacing: 10) {
                        Button("Save Key") {
                            if ControlAPIKeyStore.save(apiKeyDraft) { keySaved = ControlAPIKeyStore.hasKey }
                            apiKeyDraft = ""
                        }
                        .disabled(!containerServiceEnabled
                                  || apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
                        if keySaved {
                            Button("Clear", role: .destructive) {
                                ControlAPIKeyStore.clear()
                                keySaved = false
                            }
                            Label("Key saved", systemImage: "checkmark.seal.fill")
                                .foregroundStyle(.green).font(.caption)
                        }
                    }
                }
            }

            Section("Nucleic Control container") {
                LabeledContent("CPUs", value: "Auto")
                LabeledContent("Memory", value: "Auto")

                Picker("Memory management", selection: $memoryManagement) {
                    ForEach(MemoryManagementLevel.allCases, id: \.rawValue) { level in
                        Text(level.label).tag(level.rawValue)
                    }
                }
                .disabled(!containerServiceEnabled)

                Toggle("Agent Lifecycle Protection", isOn: $splitContainersByBackend)
                    .disabled(!containerServiceEnabled)

                Toggle("Control plane over vsock", isOn: $vsockControlPlane)
                    .disabled(!containerServiceEnabled)

                Toggle("Trace shell commands", isOn: $commandTracing)
                    .disabled(!containerServiceEnabled)
            }

            Section("Maintenance") {
                Button {
                    reclaiming = true
                    Task {
                        await store.reclaimControlMemory()
                        reclaiming = false
                    }
                } label: {
                    HStack(spacing: 6) {
                        if reclaiming { ProgressView().controlSize(.small) }
                        Text(reclaiming ? "Reclaiming…" : "Reclaim memory now")
                    }
                }
                .disabled(!containerServiceEnabled || reclaiming)

                Button {
                    restarting = true
                    Task {
                        await store.restartControlSandbox()
                        restarting = false
                    }
                } label: {
                    HStack(spacing: 6) {
                        if restarting { ProgressView().controlSize(.small) }
                        Text(restarting ? "Restarting…" : "Restart container")
                    }
                }
                .disabled(!containerServiceEnabled || restarting || recreating)

                Button(role: .destructive) {
                    recreating = true
                    Task {
                        await store.recreateControlSandbox()
                        recreating = false
                    }
                } label: {
                    HStack(spacing: 6) {
                        if recreating { ProgressView().controlSize(.small) }
                        Text(recreating ? "Re-creating…" : "Force re-creation")
                    }
                }
                .disabled(!containerServiceEnabled || recreating || restarting)
            }
        }
        .formStyle(.grouped)
    }
}

/// Everything about the chat experience: how the transcript renders, the composer
/// send key, and how completed chats are archived. The auto-archive policy syncs
/// to `AppStore` via `pushDefaults()`.
private struct ChatSettingsTab: View {
    @Environment(AppStore.self) private var store

    @AppStorage(TranscriptDisplay.showDebugKey) private var showDebugLines = false
    @AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
    @AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
    @AppStorage(AutoArchivePolicy.storageKey) private var autoArchiveRaw = AutoArchivePolicy.fallback.rawValue
    @AppStorage(ArchivedWorktreeCleanupPolicy.storageKey) private var worktreeCleanupRaw = ArchivedWorktreeCleanupPolicy.fallback.rawValue

    private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }
    private var autoArchivePolicy: AutoArchivePolicy { AutoArchivePolicy(rawValue: autoArchiveRaw) ?? .fallback }
    private var worktreeCleanupPolicy: ArchivedWorktreeCleanupPolicy {
        ArchivedWorktreeCleanupPolicy(rawValue: worktreeCleanupRaw) ?? .fallback
    }

    var body: some View {
        Form {
            Section("Transcript") {
                Toggle("Show advanced detail", isOn: $showDebugLines)

                Toggle("Show lock events", isOn: $showLockEvents)
            }

            Section("Composer") {
                Picker("Send with", selection: $submitKeyRaw) {
                    ForEach(SubmitKeyMode.allCases) { Text($0.label).tag($0.rawValue) }
                }
            }

            Section("Archiving") {
                Picker("Auto-archive completed chats", selection: $autoArchiveRaw) {
                    ForEach(AutoArchivePolicy.allCases) { Text($0.label).tag($0.rawValue) }
                }

                Picker("Delete archived chat worktrees", selection: $worktreeCleanupRaw) {
                    ForEach(ArchivedWorktreeCleanupPolicy.allCases) { Text($0.label).tag($0.rawValue) }
                }
            }
        }
        .formStyle(.grouped)
        .onAppear { pushDefaults() }
        .onChange(of: autoArchiveRaw) { _, _ in pushDefaults() }
        .onChange(of: worktreeCleanupRaw) { _, _ in pushDefaults() }
    }

    private func pushDefaults() {
        store.autoArchiveIdleInterval = autoArchivePolicy.interval
        store.archivedWorktreeCleanupInterval = worktreeCleanupPolicy.interval
    }
}

/// Defaults applied to new chats (model/effort/auto/autoship), synced to `AppStore` via
/// `pushDefaults()`. The default model also picks the backend (`BackendID.forModel`): a Claude
/// SKU runs on Claude Code, a GPT SKU on Codex.
private struct AgentsSettingsTab: View {
    @Environment(AppStore.self) private var store

    @AppStorage(ModelCatalog.defaultModelKey) private var defaultModel = ModelCatalog.fallbackModel
    @AppStorage(ModelCatalog.defaultEffortKey) private var defaultEffort = ModelCatalog.fallbackEffort
    @AppStorage(ModelCatalog.defaultAutoKey) private var defaultAuto = false
    @AppStorage(ModelCatalog.defaultAutoShipKey) private var defaultAutoShip = false
    @AppStorage(StatusIndicatorVisibility.storageKey) private var statusVisibilityRaw = StatusIndicatorVisibility.everywhere.rawValue
    @AppStorage(StatusIndicatorVisibility.chatIncidentOverrideKey) private var showStatusInChatOnIncident = true

    /// Live install + auth state per provider, populated by an async probe on appearance and
    /// re-run whenever this tab comes back into view (so logging in/out elsewhere reflects).
    @State private var providers: [ProviderStatus] = []

    var body: some View {
        Form {
            Section("New chats") {
                Picker("Default model", selection: $defaultModel) {
                    ForEach(ModelCatalog.models, id: \.self) { sku in
                        (Text(ModelCatalog.displayName(sku))
                            + (ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
                        ).tag(sku)
                    }
                }
                Picker("Default \(ModelCatalog.effortNoun(for: defaultModel).lowercased())", selection: $defaultEffort) {
                    ForEach(ModelCatalog.efforts(for: defaultModel), id: \.self) { Text(ModelCatalog.effortDisplayName($0)).tag($0) }
                }
                Toggle("Start new chats in Auto mode", isOn: $defaultAuto)
                Toggle("Autoship new chats", isOn: $defaultAutoShip)
            }

            Section("Backend") {
                ForEach(providers) { provider in
                    HStack(spacing: 8) {
                        Circle()
                            .fill(provider.isConnected ? Color.green : Color.red)
                            .frame(width: 8, height: 8)
                        Text(provider.name)
                        Spacer()
                        Text(provider.isConnected ? "Connected" : "Not Connected")
                            .foregroundStyle(.secondary)
                    }
                    .help(provider.detail)
                }
            }
            .task { providers = await ProviderAvailability.probeAll() }

            Section("Service status") {
                Picker("Show status indicator", selection: $statusVisibilityRaw) {
                    ForEach(StatusIndicatorVisibility.allCases) { Text($0.label).tag($0.rawValue) }
                }
                Toggle("Show in chat during an incident on the current model's provider", isOn: $showStatusInChatOnIncident)
                    .disabled(StatusIndicatorVisibility(rawValue: statusVisibilityRaw) == .everywhere)
                ForEach(StatusProvider.allCases) { provider in
                    Toggle("Monitor \(provider.displayName)", isOn: providerBinding(provider))
                }
            }
        }
        .formStyle(.grouped)
        .onAppear {
            defaultEffort = ModelCatalog.clampedEffort(defaultEffort, for: defaultModel)
            pushDefaults()
        }
        .onChange(of: defaultModel) { _, _ in
            // A new default model may not support the stored effort (e.g. switching to Codex
            // drops "max" → "xhigh").
            defaultEffort = ModelCatalog.clampedEffort(defaultEffort, for: defaultModel)
            pushDefaults()
        }
        .onChange(of: defaultEffort) { _, _ in pushDefaults() }
        .onChange(of: defaultAuto) { _, _ in pushDefaults() }
        .onChange(of: defaultAutoShip) { _, _ in
            if defaultAutoShip { defaultAuto = true }  // shipping implies autonomous
            pushDefaults()
        }
    }

    private func pushDefaults() {
        store.defaultModel = defaultModel
        store.defaultEffort = defaultEffort
        store.defaultAuto = defaultAuto
        store.defaultAutoShip = defaultAutoShip
    }

    /// Toggle binding for one provider's status monitoring: reads/writes the live store set
    /// (so the row reflects state immediately) and persists the choice for next launch.
    private func providerBinding(_ provider: StatusProvider) -> Binding<Bool> {
        Binding(
            get: { store.enabledStatusProviders.contains(provider) },
            set: { isOn in
                UserDefaults.standard.set(isOn, forKey: StatusIndicatorSettings.providerKey(provider))
                var enabled = store.enabledStatusProviders
                if isOn { enabled.insert(provider) } else { enabled.remove(provider) }
                store.setEnabledStatusProviders(enabled)
            })
    }
}

/// Usage: the subscription-limit gauges from the home dashboard, gathered into Settings so
/// they're checkable without leaving the panel. Claude is live — the same `QuotaCard` the home
/// view shows, reading `AppStore.subscriptionUsage`. Codex is a placeholder (`CodexUsageCard`)
/// until its usage backend is wired up. Not a `Form`: the pane shows the dashboard cards
/// verbatim so the two views stay identical.
private struct UsageSettingsTab: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 20) {
                Text("The same account-wide subscription limits shown on the home dashboard.")
                    .settingsCaption()

                QuotaCard(title: "Claude")
                CodexUsageCard()
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(20)
        }
    }
}

/// Apple Intelligence model choice (used for summaries and auto-named chats), the
/// AI tool-call summarization options that depend on it, and its live availability
/// status. Summarization preferences sync to `AppStore` via `pushDefaults()`.
private struct IntelligenceSettingsTab: View {
    @Environment(AppStore.self) private var store

    @AppStorage(IntelligenceChoice.storageKey) private var choiceRaw = IntelligenceChoice.onDevice.rawValue
    @AppStorage(TranscriptDisplay.summarizeToolCallsKey) private var summarizeToolCalls = true
    @AppStorage(TranscriptDisplay.toolSummaryScopeKey) private var toolSummaryScopeRaw = ToolSummaryScope.bashOnly.rawValue

    private var choice: IntelligenceChoice { IntelligenceChoice(rawValue: choiceRaw) ?? .onDevice }
    private var toolSummaryScope: ToolSummaryScope { ToolSummaryScope(rawValue: toolSummaryScopeRaw) ?? .bashOnly }

    var body: some View {
        Form {
            Section("Model") {
                Picker("Model", selection: $choiceRaw) {
                    ForEach(IntelligenceChoice.allCases) { option in
                        // Energy Aware switches on AC vs. battery, so it's meaningless on a
                        // desktop — gray it out there (desktops have no battery to detect).
                        Text(option.label)
                            .tag(option.rawValue)
                            .disabled(option == .energyAware && !PowerSource.hasBattery)
                    }
                }
                .pickerStyle(.radioGroup)
            }

            Section("Tool call summaries") {
                Toggle("Summarize tool calls with AI", isOn: $summarizeToolCalls)
                if summarizeToolCalls {
                    Picker("AI summarizes", selection: $toolSummaryScopeRaw) {
                        Text("Bash commands only").tag(ToolSummaryScope.bashOnly.rawValue)
                        Text("All tool calls").tag(ToolSummaryScope.all.rawValue)
                    }
                }
            }

            Section("Status") {
                LabeledContent("On-device model", value: IntelligenceAvailability.status(for: .onDevice))
                LabeledContent("Private Cloud Compute", value: IntelligenceAvailability.status(for: .privateCloudCompute))
                if PowerSource.hasBattery {
                    LabeledContent("Energy Aware", value: IntelligenceAvailability.status(for: .energyAware))
                }
            }
            .font(.callout)
        }
        .formStyle(.grouped)
        .onAppear { pushDefaults() }
        .onChange(of: summarizeToolCalls) { _, _ in pushDefaults() }
        .onChange(of: toolSummaryScopeRaw) { _, _ in pushDefaults() }
    }

    private func pushDefaults() {
        store.summarizeToolCalls = summarizeToolCalls
        store.toolSummaryScope = toolSummaryScope
    }
}

/// The "Orbital" tab: the meshing service, device pairing, and the sibling Macs meshed with
/// this one (host side).
private struct RemoteSettingsTab: View {
    var body: some View {
        Form {
            RemoteAccessSection()
        }
        .formStyle(.grouped)
    }
}

private extension View {
    /// Shared styling for the small explanatory captions beneath settings controls.
    func settingsCaption() -> some View {
        self.font(.caption)
            .foregroundStyle(.secondary)
            .fixedSize(horizontal: false, vertical: true)
    }
}

/// Canonical links into the public support site (`nucleic.blakeslee.xyz/support`). The full
/// how-to lives on the web, so a toggle keeps a one-line hint here and defers the detail —
/// setup steps, requirements, gotchas — to the matching article via `LearnMoreLink`.
private enum SupportURL {
    private static let base = "https://nucleic.blakeslee.xyz"
    static let home = URL(string: "\(base)/support.html")!
    static let nucleicControl = URL(string: "\(base)/support/nucleic-control.html")!
    static let sandboxes = URL(string: "\(base)/support/sandboxes.html")!
    static let macVMs = URL(string: "\(base)/support/virtual-machines.html")!
    static let macVMBaseImage = URL(string: "\(base)/support/macos-vm-base-image.html")!
    static let macVMComputerUse = URL(string: "\(base)/support/macos-vm-computer-use.html")!
    static let macVMAXAgent = URL(string: "\(base)/support/macos-vm-ax-agent.html")!
}

/// A compact, caption-weight "Learn more" link to a support article. Sits directly under a
/// toggle's short hint, replacing the long inline instructions that used to clutter the UI.
private struct LearnMoreLink: View {
    private let label: String
    private let url: URL

    init(_ label: String = "Learn more", url: URL) {
        self.label = label
        self.url = url
    }

    var body: some View {
        Link(destination: url) {
            HStack(spacing: 3) {
                Text(label)
                Image(systemName: "arrow.up.right").imageScale(.small)
            }
            .font(.caption)
        }
        .fixedSize(horizontal: false, vertical: true)
    }
}
