import AppKit
import SwiftUI
import NucleicCore

/// The macOS cockpit (PLAN "macOS UI"). A thin SwiftUI shell over `AppStore` — it
/// holds no canonical state, just observes the store and sends intents (RUNTIME §3).
///
/// Runs as a SwiftPM executable; launch with
/// `swift run NucleicApp`. The store persists under
/// `~/Library/Application Support/Nucleic`.
@main
struct NucleicApp: App {
    @State private var store: AppStore
    // In-app auto-update for direct (non-App-Store) distribution. Dormant unless the build
    // embeds an appcast feed (packaged canary/beta/rc/stable); see AppUpdater.
    @StateObject private var updater = AppUpdater()

    init() {
        // Startup instrumentation (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 6/9): open the
        // launch → first-interactive-frame signpost as the first code we own (the dyld +
        // runtime slice before init is Instruments' App Launch template's territory). Closed
        // when the root view's `.task` fires — see the `.task` below.
        StartupSignposts.begin(.launchToFirstFrame)
        // A GUI-launched app inherits the launchd environment, which here carries a *blank*
        // ANTHROPIC_API_KEY. Claude Code prefers ANTHROPIC_API_KEY over the OAuth login, so every
        // claude session — host-spawned (which inherits this whole environment) and sandboxed
        // alike — would attempt empty API-key auth and 401. Purge a blank key before anything
        // spawns; a real key is left intact for users who actually authenticate that way.
        if SessionController.isBlankAPIKey(ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"]) {
            unsetenv("ANTHROPIC_API_KEY")
        }
        // Same hazard for Grok Build, which prefers XAI_API_KEY over the `grok` login: a blank
        // key inherited from the launchd environment would make every grok session attempt
        // empty-key auth and 401. Purge a blank key; a real one is left intact (GROK_ADAPTER §4).
        if SessionController.isBlankAPIKey(ProcessInfo.processInfo.environment["XAI_API_KEY"]) {
            unsetenv("XAI_API_KEY")
        }
        // Same for the other ACP wrapper agents that prefer an API-key env var over their CLI
        // login (OpenCode/Cursor); purge any blank inherited key so a login isn't shadowed.
        for key in ["OPENCODE_API_KEY", "CURSOR_API_KEY", "HERMES_API_KEY"] {
            if SessionController.isBlankAPIKey(ProcessInfo.processInfo.environment[key]) {
                unsetenv(key)
            }
        }

        // Show a Dock icon + foreground window when launched from the terminal.
        NSApplication.shared.setActivationPolicy(.regular)

        // Watch for main-thread hangs from launch on: a wedged main thread self-samples into
        // Application Support/Nucleic/hang-reports so a beach-ball leaves a stack to diagnose
        // instead of vanishing with the force-quit (see MainThreadHangReporter).
        MainThreadHangReporter.shared.start()

        let support = Self.supportDirectory()
        do {
            let database = try GRDBMetadataStore(
                path: support.appendingPathComponent("nucleic.sqlite").path)
            let worktrees = GitWorktreeManager()
            let transcriptsDir = support.appendingPathComponent("sessions", isDirectory: true)
            // Shared approval-server registry: sessions that share a Nucleic Control container share
            // one token-multiplexed approval server (so they share that container's single relayed
            // control socket). Handed to every Claude backend; host/per-session runs ignore it.
            // Created before the container manager so the latter can release a container's server the
            // moment that container is permanently removed.
            let approvalServerRegistry = ApprovalServerRegistry()
            // Shared sandbox orchestrator: opt-in per project, dormant unless a session's
            // project enables it. The same instance is handed to every backend so container
            // lifecycle (idle stop, teardown, reconcile) stays consistent. When a shared control
            // container is permanently removed, drop its approval server + control socket so the next
            // session in a freshly-created container binds a clean one.
            let containerManager = ContainerManager(
                onSharedContainerRemoved: { name in
                    await approvalServerRegistry.teardown(containerName: name)
                })
            // macOS-guest VM orchestrator (Apple `Virtualization`): one shared instance backs both the
            // `mac_vm_exec` tool (per-session isolated Macs for Xcode/simulator work) and the Virtual
            // Machines settings panel. Dormant unless the macOS-VM service is enabled (docs/MACOS_VM.md).
            // Nucleic mediates the Claude OAuth flow itself (no external terminal / CLI login).
            // The broker owns PKCE, the loopback redirect capture, and the token exchange; the app
            // only supplies the two UI seams it can't reach from NucleicCore: opening the consent
            // page in the browser, and the copy-paste fallback when a loopback port can't bind.
            let claudeCredentialBroker = ClaudeCredentialBroker(
                containerManager: containerManager,
                openURL: { url in
                    Task { @MainActor in NSWorkspace.shared.open(url) }
                },
                promptForCode: { url in await ClaudeLoginPrompt.requestCode(consentURL: url) })
            // Codex (ChatGPT) sign-in — same Nucleic-mediated OAuth, writing ~/.codex/auth.json.
            // Codex's client mandates the fixed localhost:1455 redirect, so there's no paste fallback.
            let codexCredentialBroker = CodexCredentialBroker(
                openURL: { url in
                    Task { @MainActor in NSWorkspace.shared.open(url) }
                })
            let macVMManager = MacVMManager()
            // Shared conflict coordinator: every backend consults it from the `check_conflict`
            // MCP tool; the store registers itself as its arbiter at startup (see `.task`).
            let conflictCoordinator = ConflictCoordinator()
            let store = AppStore(
                database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
                containerManager: containerManager,
                claudeCredentialBroker: claudeCredentialBroker,
                codexCredentialBroker: codexCredentialBroker,
                macVMManager: macVMManager, conflictCoordinator: conflictCoordinator
            ) { session in
                switch session.backend {
                case .codex:
                    // Codex app-server backend: interactive approvals over JSON-RPC (ADAPTERS §2).
                    // Joins the lock/arbitration system via the conflict coordinator (file-change
                    // approvals acquire locks; the interceptor releases on merge); execs in its
                    // control container and receives the same platform MCP tools as Claude.
                    return CodexAppServerBackend(
                        containerManager: containerManager,
                        macVMManager: macVMManager,
                        conflictCoordinator: conflictCoordinator,
                        approvalServerRegistry: approvalServerRegistry)
                case .codexExec:
                    // Unattended Codex (codex exec --json), fail-closed (ADAPTERS §3). Execs in its
                    // control container on the vsock control-plane path; `--ask-for-approval never`
                    // means no acquire seam, so it joins the lock system on observe/release only.
                    return CodexExecBackend(
                        containerManager: containerManager,
                        macVMManager: macVMManager,
                        conflictCoordinator: conflictCoordinator,
                        approvalServerRegistry: approvalServerRegistry)
                case .claudeCode:
                    // Each chat turn is a single-shot run (stdin closed), with follow-ups
                    // resumed via --resume. This is the only reliable way to use the
                    // approval server: Claude's streaming-input mode hangs alongside
                    // --permission-prompt-tool. Approval server is authoritative
                    // (--permission-mode default); child is hermetic (--strict-mcp-config).
                    return ClaudeCodeBackend(
                        configuration: .init(closeStdinAfterPrompt: true),
                        containerManager: containerManager,
                        macVMManager: macVMManager,
                        conflictCoordinator: conflictCoordinator,
                        approvalServerRegistry: approvalServerRegistry,
                        // A host-run turn authenticates with the login the user signed into through
                        // Nucleic (refreshed on demand), not `claude`'s own credential store.
                        oauthTokenProvider: { await claudeCredentialBroker.freshAccessToken() })
                case .grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp:
                    // ACP agents (`grok agent stdio`, `opencode acp`, `openclaw acp`, `hermes acp`,
                    // `cursor-agent acp`): standards JSON-RPC over stdio with a native
                    // `session/request_permission` approval request, suspending on the same
                    // ApprovalCoordinator as Claude/Codex (GROK_ADAPTER §3–4, ADAPTERS §5). One
                    // generic `ACPBackend` drives them all — only the per-agent profile differs.
                    // The conflict coordinator joins these sessions to the lock system + autoship;
                    // on the vsock control-plane path they exec in their control container with the
                    // interceptor wired. A non-ACP backend here is unreachable (compiler-checked).
                    let agent = ACPAgent.forBackend(session.backend) ?? .grok
                    return ACPBackend(
                        configuration: .init(agent: agent),
                        conflictCoordinator: conflictCoordinator,
                        containerManager: containerManager,
                        macVMManager: macVMManager,
                        approvalServerRegistry: approvalServerRegistry)
                }
            }
            store.intelligence = AppleIntelligenceProvider()
            // Offer this Mac as a mesh intelligence executor (COVALENCE_RUNNER §5): a runner
            // peer's AFM queue may place delegated work here. Desktop class — eligible for
            // every priority tier — with the chip name driving the queue's power bias. Only
            // where Foundation Models exist; if the model is off/unavailable at request time
            // the executor answers an error and the runner falls back to heuristics.
            if #available(macOS 26, *) {
                store.meshIntelligenceProfile = IntelligenceWorkerProfile(
                    deviceClass: .desktop, chip: DeviceCapability.chipName)
            }
            // Play a macOS system sound when a chat finishes (Funk) or starts needing the
            // user (Pop). The handler honors the per-cue toggles in Settings ▸ Chats; the
            // store fires only on live transitions, so launch/resume stay silent.
            store.chatStatusSoundHook = { ChatStatusSounds.play($0) }
            // Drive the escalating alarm: the store flips this true while a chat sits blocked
            // and unviewed, false once the user opens it (or it resolves). `ChatAlarm` honors
            // the off-by-default Settings switch and owns the repeating "Submarine" pulse.
            store.chatAlarmHook = { ChatAlarm.shared.setActive($0) }
            // Bounce the Dock icon once a chat has needed the user for a full minute, calling
            // them back until they foreground the app. Same blocked-and-unviewed edge as the
            // alarm; `DockBounce` owns the one-minute delay and the on-by-default switch.
            store.chatDockBounceHook = { DockBounce.shared.setPending($0) }
            store.defaultModel = ModelCatalog.storedDefaultModel
            store.defaultEffort = ModelCatalog.storedDefaultEffort
            // Normalized through `storedSupervisorChoice` so a supervisor stored before the list was
            // shortened (or with an effort no longer offered) launches on a real entry.
            store.defaultSupervisorModel = ModelCatalog.storedSupervisorChoice.model
            store.defaultSupervisorEffort = ModelCatalog.storedSupervisorChoice.effort
            store.defaultOrchestraWorkerModel = ModelCatalog.storedDefaultOrchestraWorkerModel
            store.defaultOrchestraMaxConcurrentWorkers = ModelCatalog.storedDefaultOrchestraMaxWorkers
            store.defaultIntelligenceLevel = ModelCatalog.storedDefaultIntelligenceLevel
            store.defaultAuto = ModelCatalog.storedDefaultAuto
            store.defaultAutoShip = ModelCatalog.storedDefaultAutoShip
            store.summarizeToolCalls = TranscriptDisplay.storedSummarizeToolCalls
            store.toolSummaryScope = TranscriptDisplay.storedToolSummaryScope
            store.autoArchiveIdleInterval = AutoArchivePolicy.stored.interval
            store.archivedWorktreeCleanupInterval = ArchivedWorktreeCleanupPolicy.stored.interval
            store.setEnabledStatusProviders(StatusIndicatorSettings.storedEnabledProviders)
            // Hand the sync host the account-aware model/effort catalog so paired phones render
            // the same picker (including Sol's Pro-only level) as this Mac.
            store.setSyncModelCatalog(ModelCatalog.wireCatalog(codexPro: store.isCodexPro))
            _store = State(initialValue: store)
        } catch {
            fatalError("Could not open the Nucleic store at \(support.path): \(error)")
        }
    }

    /// Settle the new-chat default on a model the user can actually run. `init` seeds the stored
    /// choice (else Opus) synchronously so the picker shows instantly; once the async provider
    /// probes resolve, if that seed's backend isn't connected (installed + authenticated) we swap
    /// to the highest-priority *connected* provider's default — Claude → Codex → Grok — and persist
    /// it so Settings and new chats stay in step. (Status-driven failover still layers on top later.)
    @MainActor
    private func reconcileDefaultModel() async {
        // Probe through the store so the composer's Intelligence router shares the result.
        await store.refreshConnectedProviders()
        let connected = store.connectedProviders
        let resolved = ModelCatalog.resolvedDefault(
            connected: connected, codexPro: store.isCodexPro)
        guard resolved.model != store.defaultModel || resolved.effort != store.defaultEffort
        else { return }
        store.defaultModel = resolved.model
        store.defaultEffort = resolved.effort
        UserDefaults.standard.set(resolved.model, forKey: ModelCatalog.defaultModelKey)
        UserDefaults.standard.set(resolved.effort, forKey: ModelCatalog.defaultEffortKey)
    }

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(store)
                .environmentObject(updater)
                .onChange(of: store.codexSubscriptionPlan) { _, _ in
                    // Login or a Codex token rotation can change the plan while the app is open.
                    // Keep the catalog served to newly connecting phones in lockstep.
                    store.setSyncModelCatalog(
                        ModelCatalog.wireCatalog(codexPro: store.isCodexPro))
                }
                // Keep this floor low enough that the window can always satisfy its layout on a
                // narrow or transiently low-res display (e.g. a macOS VM that presents a ~1024×768
                // fallback framebuffer before negotiating its full mode). Above ~900 the split
                // view can't fit sidebar + detail at their minimums, so it overlaps them and the
                // composer's text wraps a character per line — the garbled render seen in VMs.
                // Below this floor the existing responsive machinery (the sidebar collapses; the
                // home dashboard stacks its cards past its 600pt threshold) reflows cleanly, so a
                // squeezed window degrades gracefully instead of breaking.
                .frame(minWidth: 720, minHeight: 480)
                .task {
                    // This task fires when the root view first appears — the first frame at
                    // which the window's content is live — so it closes the launch span opened
                    // in `init` and opens the hydration span
                    // (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 6/9 instrumentation).
                    StartupSignposts.end(.launchToFirstFrame)
                    StartupSignposts.begin(.hydration)
                    // Re-arm Smart Sleep's always-on Dark Wake baseline after relaunch.
                    SleepBlocker.shared.applyStoredSetting()
                    // Wire the host-side computer-use surface (VZVirtualMachineView framebuffer capture
                    // + HID) so macOS-VM computer use runs SIP-free through the guest's virtual display
                    // (docs/MACOS_VM.md §12). Lives in the app layer because it needs AppKit.
                    // Deliberately BEFORE the deferred maintenance kicks off below: the stale-base
                    // re-provision it spawns needs the surface host to drive a macOS guest.
                    #if arch(arm64)
                    await store.setMacVMSurfaceHost(MacVMComputerSurface())
                    #endif
                    await store.activateConflictArbitration()
                    // Ordered hydration (plan item 6: project/session install stays first —
                    // everything downstream keys off the loaded projects + installed
                    // controllers). loadProjects() also reconciles the container-service lock:
                    // it pins the service on when a Control project exists and, on a fresh
                    // install, adopts any leftover controlled repos under ~/.nucleic/control/.
                    await store.loadProjects()
                    // Deferring mode splits the old inline maintenance tail off the launch path:
                    // lock/nvrsion reconciliation starts on its own task (commands gate on it —
                    // no turn can run against unreconciled lock state), and the orphan/GC
                    // maintenance waits for runDeferredStartupMaintenanceOnce() below.
                    await store.loadSessions(deferringStartupMaintenance: true)
                    // Noncritical hydration, CONCURRENT once sessions are installed (plan item
                    // 6): the three are mutually independent — the activity feed reads the
                    // durable cast table, todos their own table, and the provider probe shells
                    // out to each agent CLI — so they no longer serially extend the
                    // time-to-settled-UI (the probe alone could dominate it).
                    async let activityFeedSeeded: Void =
                        // Seed the fleet activity feed from the durable cast table (mesh casting)
                        // — items published while the app was closed render immediately.
                        store.seedActivityFeed()
                    async let todosLoaded: Void = store.loadTodos()
                    async let defaultModelReconciled: Void = reconcileDefaultModel()
                    _ = await (activityFeedSeeded, todosLoaded, defaultModelReconciled)
                    StartupSignposts.end(.hydration)
                    store.startQuotaPolling()
                    store.startStatusPolling()
                    store.startAutoArchivePolling()
                    // Start Covalence remote access. It's a mandatory, always-on feature, and the
                    // sync server's running state is in-memory only, so it must start on every
                    // launch (an update relaunches the process) for paired devices to reconnect. Off
                    // the startup path — a tailnet start can block on a browser login for minutes.
                    Task { await store.resumeRemoteAccess() }
                    // Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4).
                    Task { await HeartbeatReporter.reportIfDue() }
                    // Deferred startup maintenance (plan item 6), now that the first paint and
                    // hydration are behind us: mesh-transfer crash recovery, orphaned
                    // Orchestra-worker + scratch-dir cleanup, concurrent container/VM-disk GC,
                    // the keyword backfill, and the stale-base re-provision check (formerly all
                    // inline on the launch path). Runs exactly once, on a store-owned task, so
                    // closing the window mid-launch can't strand it half-run.
                    store.runDeferredStartupMaintenanceOnce()
                    // Task.sleep does not itself wake a sleeping Mac. When Dark Wake gives this
                    // process CPU time, the overdue timer resumes and pulls every durable mesh gap.
                    Task {
                        while !Task.isCancelled {
                            try? await Task.sleep(for: .seconds(60))
                            guard SleepBlocker.isEnabled else { continue }
                            await store.reconcileMeshDuringDarkWake()
                        }
                    }
                }
                .onChange(of: store.localTurnInFlightCount, initial: true) { _, count in
                    SleepBlocker.shared.updateLocalTurnCount(count)
                }
                .onReceive(
                    NSWorkspace.shared.notificationCenter.publisher(
                        for: NSWorkspace.didWakeNotification)
                ) { _ in
                    Task { await store.reconcileMeshAfterWake() }
                }
                // Re-check the Smart Sleep helper whenever the app comes forward. The reconcile loop
                // stops retrying once the helper needs approval (rather than hammering smd), so this is
                // what picks the lease back up after the user approves the login item and returns.
                .onReceive(
                    NotificationCenter.default.publisher(
                        for: NSApplication.didBecomeActiveNotification)
                ) { _ in
                    SleepBlocker.shared.recheckHelperApproval()
                }
        }
        // SwiftUI otherwise derives a new window's initial size from the root view's fitting
        // size, which is close to our deliberately small 720×480 resize floor. That makes the
        // first launch open with most of the sidebar and dashboard clipped. Give fresh windows
        // a useful working size while leaving the smaller minimum above in place for compact
        // displays and user-driven resizing. AppKit will constrain this to the visible screen.
        .defaultSize(width: 1280, height: 800)
        .windowToolbarStyle(.unified)
        .commands {
            // "Check for Updates…" lives in Settings ▸ General now (see GeneralSettingsTab), not
            // the app menu — background updates surface as an unobtrusive sidebar banner instead.
            CommandGroup(replacing: .newItem) {
                // Cmd-N floats the quick-launch composer and, on start, leaves you on your
                // current view — the new chat runs in the background.
                Button("New Chat in Background") {
                    store.presentNewChatComposer(background: true)
                }
                .keyboardShortcut("n", modifiers: .command)
                // Cmd-Shift-N floats the same composer but jumps to the new session once it starts.
                Button("New Chat…") {
                    store.presentNewChatComposer(background: false)
                }
                .keyboardShortcut("n", modifiers: [.command, .shift])
                // Opt-Cmd-N opens the composer straight in ephemeral (Scratch) chat mode — a
                // throwaway, out-of-project chat that runs in its own popup window.
                Button("New Scratch Chat") {
                    store.presentNewChatComposer(background: true, ephemeral: true)
                }
                .keyboardShortcut("n", modifiers: [.command, .option])
                // Cmd-T captures a quick idea into the home to-do inbox from anywhere.
                Button("New To-Do") {
                    store.presentQuickTodo()
                }
                .keyboardShortcut("t", modifiers: .command)
            }
            // The Covalence mesh diagnostics window (docs/COVALENCE_QUEUE.md): live mesh state,
            // health, and the structural event log. Lives in the Window menu — it's an
            // auxiliary observability surface, not a document.
            CommandGroup(before: .windowList) {
                Button("Covalence Mesh Viewer") {
                    CovalenceMeshViewerWindowController.shared.open(store: store)
                }
                .keyboardShortcut("m", modifiers: [.command, .option])
            }
        }
        Settings {
            SettingsView().environment(store).environmentObject(updater)
        }
    }

    private static func supportDirectory() -> URL {
        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        let dir = base.appendingPathComponent("Nucleic", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir
    }
}
