//
// OpenRocky — Voice-first AI Agent
// https://github.com/openrocky
//
// Developed by everettjf with the assistance of Claude Code and Codex.
// Date: 2026-03-25
// Copyright (c) 2026 everettjf. All rights reserved.
//

import Combine
import Foundation
import LanguageModelChatUI

@MainActor
final class OpenRockySessionRuntime: ObservableObject {
    @Published private(set) var session: OpenRockyPreviewSession
    @Published private(set) var statusText = "Voice runtime is idle."
    @Published private(set) var isMicrophoneActive = false
    /// Set when the runtime needs the UI to surface a permission prompt before proceeding.
    /// Views observe this and present the corresponding sheet.
    @Published var pendingPermissionPrompt: OpenRockyPermissionPromptKind?
    /// Live snapshot of the in-flight delegate-task. Non-nil between
    /// planStarted and a short delay after planCompleted so the voice
    /// overlay can show the user which sub-tools are running.
    @Published private(set) var liveDelegateProgress: OpenRockyDelegateProgress?

    private let bridge = OpenRockyRealtimeVoiceBridge()
    private let toolbox = OpenRockyToolbox()
    private let characterStore = OpenRockyCharacterStore.shared
    private let storage = OpenRockyPersistentStorageProvider.shared
    private let usageService = OpenRockyUsageService.shared
    var conversationID: String = ""
    private var userTranscriptBuffer = ""
    private var assistantTranscriptBuffer = ""
    private var lastToolName: String?
    private var chatConfiguration = OpenRockyProviderConfiguration(provider: .openAI, modelID: OpenRockyProviderKind.openAI.defaultModel)
    private var voiceConfiguration = OpenRockyRealtimeProviderConfiguration(provider: .openAI, modelID: OpenRockyRealtimeProviderKind.openAI.defaultModel)
    /// Active tool execution tasks from voice provider — cancelled on voice stop / turn reset.
    private var toolTasks: [Task<Void, Never>] = []
    /// Tool calls completed by the realtime voice provider during the current turn.
    private var realtimeCompletedToolCalls: [OpenRockyCompletedToolCall] = []
    /// Tool calls completed by the chat sub-agent inside delegate-task during
    /// the current turn. These get persisted alongside `realtimeCompletedToolCalls`
    /// so the chat detail view can show the full tool tree (each MCP call,
    /// each skill, each apple-tool) instead of burying everything in the
    /// delegate-task wrapper's JSON result.
    private var subagentCompletedToolCalls: [OpenRockyCompletedToolCall] = []
    /// Token of a pending live-progress clear, so a new delegate-task that
    /// starts before the fade-out completes can cancel the old timer instead
    /// of having it nuke the new state.
    private var liveProgressClearTask: Task<Void, Never>?

    init() {
        let provider = ProviderStatus(
            name: OpenRockyRealtimeProviderKind.openAI.displayName,
            model: OpenRockyRealtimeProviderKind.openAI.defaultModel,
            isConnected: false
        )
        session = .liveSeed(provider: provider)

        // Wire up subagent progress events from toolbox → session timeline + statusText.
        // The realtime model calls `delegate-task`, which spawns a sub-agent that
        // emits structured events tool-by-tool. We render each as a timeline entry
        // (so the user can watch the back-end agent work) and update statusText so
        // the live status line reflects the current step.
        toolbox.subagentEventHandler = { [weak self] event in
            self?.handleSubagentEvent(event)
        }
    }

    func syncProviders(
        chatConfiguration: OpenRockyProviderConfiguration,
        voiceConfiguration: OpenRockyRealtimeProviderConfiguration
    ) {
        self.chatConfiguration = chatConfiguration.normalized()
        self.voiceConfiguration = voiceConfiguration.normalized()
        // Keep the toolbox's subagent chat configuration in sync — delegate-task
        // dispatches to whatever chat provider is currently active.
        toolbox.subagentChatConfiguration = self.chatConfiguration

        session.provider = ProviderStatus(
            name: voiceConfiguration.provider.displayName,
            model: voiceConfiguration.modelID,
            isConnected: voiceConfiguration.isConfigured
        )
    }

    func toggleVoiceSession(voiceConfiguration: OpenRockyRealtimeProviderConfiguration) {
        if isMicrophoneActive {
            stopVoiceSession()
        } else {
            // Gate on mic permission. For voice-first UX we either prime first-time users
            // or deep-link deniers to Settings rather than failing silently in the mic loop.
            switch OpenRockyPermissionCenter.microphoneStatus {
            case .granted:
                startVoiceSession(configuration: voiceConfiguration)
            case .undetermined:
                pendingPermissionPrompt = .micPriming
            case .denied:
                pendingPermissionPrompt = .micDenied
            }
        }
    }

    /// Call this from the priming sheet's "Continue" action. Requests the system permission,
    /// then either starts the session or falls back to the denied variant.
    func continueAfterMicPriming(voiceConfiguration: OpenRockyRealtimeProviderConfiguration) {
        pendingPermissionPrompt = nil
        Task {
            let granted = await OpenRockyPermissionCenter.requestMicrophone()
            if granted {
                startVoiceSession(configuration: voiceConfiguration)
            } else {
                pendingPermissionPrompt = .micDenied
            }
        }
    }

    func startVoiceSession(configuration: OpenRockyRealtimeProviderConfiguration) {
        var config = configuration
        let character = characterStore.activeCharacter
        config.characterName = character.name
        config.characterSpeakingStyle = character.speakingStyle
        config.characterGreeting = character.greeting.isEmpty ? nil : character.greeting
        // Apply character's voice preferences if the provider instance doesn't specify one
        if config.openaiVoice == nil, let voice = character.openaiVoice {
            config.openaiVoice = voice
        }

        syncProviders(chatConfiguration: chatConfiguration, voiceConfiguration: config)
        // Clear all previous voice state for a fresh session
        resetAssistantTurn()
        userTranscriptBuffer = ""
        session.liveTranscript = ""
        session.timeline.removeAll()
        session.mode = .planning
        statusText = "Starting voice session..."
        refreshPlan(connect: .active, capture: .queued, tool: .queued, answer: .queued)

        // Prime the realtime session with prior turns of this conversation so
        // the model can continue where the user left off. Empty list for fresh
        // conversations, in which case the persona greeting takes over.
        let priming = primingItemsForCurrentConversation()
        if !priming.isEmpty {
            rlog.info("Priming realtime with \(priming.count) prior turn(s)", category: "Session")
            appendTimeline(kind: .system, text: "Replaying \(priming.count) prior turn(s) into the live runtime.")
        }

        rlog.info("Starting realtime voice session: provider=\(config.provider.rawValue) model=\(config.modelID)", category: "Session")
        Task {
            do {
                try await bridge.startIfNeeded(
                    configuration: config,
                    voiceInputEnabled: true,
                    soulInstructions: characterStore.voiceSystemPrompt,
                    realtimeTools: toolbox.realtimeTools(),
                    primingItems: priming,
                    eventSink: makeEventSink()
                )
            } catch {
                handle(error: error)
            }
        }
    }

    /// Pulls priming items for the active conversation. Empty when there is no
    /// conversationID yet (the user is on a fresh home surface, not a saved
    /// chat) or when the conversation has no eligible text turns.
    private func primingItemsForCurrentConversation() -> [OpenRockyVoicePrimingItem] {
        guard !conversationID.isEmpty else { return [] }
        let messages = storage.messages(in: conversationID)
        return OpenRockyVoicePriming.items(from: messages)
    }

    func stopVoiceSession() {
        rlog.info("Stopping voice session", category: "Session")
        isMicrophoneActive = false
        session.mode = .ready
        statusText = "Voice session stopped."
        refreshPlan(connect: .queued, capture: .queued, tool: .queued, answer: .queued)
        appendTimeline(kind: .system, text: "Voice session stopped and the live runtime was disconnected.")

        Task {
            await bridge.stop()
        }
    }

    private func makeEventSink() -> @Sendable (OpenRockyRealtimeEvent) -> Void {
        { [weak self] event in
            Task { @MainActor [weak self] in
                self?.handle(event: event)
            }
        }
    }

    private func handle(event: OpenRockyRealtimeEvent) {
        switch event {
        case .status(let text):
            statusText = text
        case .sessionReady(let model):
            session.provider = ProviderStatus(
                name: session.provider.name,
                model: model,
                isConnected: true
            )
            session.mode = isMicrophoneActive ? .listening : .ready
            statusText = isMicrophoneActive ? "Listening for voice input..." : "Realtime session ready for text and follow-up."
            refreshPlan(connect: .done, capture: isMicrophoneActive ? .active : .queued, tool: .queued, answer: .queued)
            appendTimeline(kind: .system, text: "Realtime model `\(model)` is attached to the home runtime.")
        case .inputSpeechStarted:
            // New turn — clear buffers from previous turn. Also drop any
            // realtime + subagent tool calls that didn't make it into a save
            // (e.g., the prior turn's assistantTranscriptFinal arrived empty),
            // so they don't latch onto this turn's persisted record.
            userTranscriptBuffer = ""
            assistantTranscriptBuffer = ""
            realtimeCompletedToolCalls.removeAll()
            subagentCompletedToolCalls.removeAll()
            Task {
                await bridge.interruptPlayback()
            }
        case .microphoneActive(let isActive):
            isMicrophoneActive = isActive
            session.mode = isActive ? .listening : .ready
            statusText = isActive ? "Listening for voice input..." : statusText
        case .userTranscriptDelta(let delta):
            userTranscriptBuffer = delta
            session.liveTranscript = delta
            session.mode = .listening
            refreshPlan(connect: .done, capture: .active, tool: .queued, answer: .queued)
        case .userTranscriptFinal(let text):
            let finalText = text.trimmingCharacters(in: .whitespacesAndNewlines)
            guard finalText.isEmpty == false else { return }
            userTranscriptBuffer = finalText
            session.liveTranscript = finalText
            session.mode = .planning
            statusText = "Planning from live transcript..."
            refreshPlan(connect: .done, capture: .done, tool: .active, answer: .queued)
            appendTimeline(kind: .speech, text: "Voice transcript: \(finalText)")
        case .assistantTranscriptDelta(let delta):
            if assistantTranscriptBuffer.isEmpty {
                assistantTranscriptBuffer = delta
            } else {
                assistantTranscriptBuffer += delta
            }
            session.assistantReply = assistantTranscriptBuffer
            session.mode = .executing
            statusText = "Rocky is responding..."
            refreshPlan(connect: .done, capture: .done, tool: lastToolName == nil ? .done : .active, answer: .active)
        case .assistantTranscriptFinal(let text):
            let finalText = text.trimmingCharacters(in: .whitespacesAndNewlines)
            let resolvedText = finalText.isEmpty ? assistantTranscriptBuffer : finalText
            guard resolvedText.isEmpty == false else {
                // TTS finished but no transcript text — still must resume mic.
                session.mode = .ready
                statusText = "Rocky is ready for the next follow-up."
                Task { await bridge.resumeMicAfterPlayback() }
                lastToolName = nil
                return
            }
            assistantTranscriptBuffer = resolvedText
            session.assistantReply = resolvedText
            session.mode = .ready
            statusText = "Rocky is ready for the next follow-up."
            refreshPlan(connect: .done, capture: .done, tool: .done, answer: .done)
            appendTimeline(kind: .result, text: resolvedText)
            saveVoiceTurnToConversation(userText: userTranscriptBuffer, assistantText: resolvedText)
            // Resume mic after assistant finishes speaking (echo suppression lift)
            Task { await bridge.resumeMicAfterPlayback() }
            lastToolName = nil
        case .usageReported(_, _, let total):
            // Server-reported token usage from response.done. Replaces the previous
            // transcript-length estimate that ran here on every assistantTranscriptFinal.
            if total > 0 {
                usageService.recordVoice(
                    provider: voiceConfiguration.provider.displayName,
                    model: voiceConfiguration.modelID,
                    totalTokens: total
                )
            }
        case .assistantAudioChunk:
            Task {
                await bridge.handlePlaybackEvent(event)
            }
        case .assistantAudioDone:
            Task {
                await bridge.flushBufferedAudio()
            }
        case .toolCallRequested(let name, let arguments, let callID):
            lastToolName = name
            session.mode = .executing
            statusText = "Running \(name)..."
            refreshPlan(connect: .done, capture: .done, tool: .active, answer: .queued, toolDetail: "Executing \(name) with live runtime arguments.")
            appendTimeline(kind: .tool, text: "Requested tool call: `\(name)`")

            let toolTask = Task {
                do {
                    rlog.info("Voice tool executing: \(name) args=\(arguments.prefix(200))", category: "Session")
                    let output = try await toolbox.execute(name: name, arguments: arguments)
                    guard !Task.isCancelled else { return }
                    self.realtimeCompletedToolCalls.append(.init(
                        id: callID, name: name, arguments: arguments,
                        result: output, succeeded: true
                    ))
                    appendTimeline(kind: .tool, text: "Completed tool call: `\(name)`")
                    try await bridge.sendToolOutput(callID: callID, output: output)
                } catch {
                    guard !Task.isCancelled else { return }
                    let message = error.localizedDescription
                    let nsError = error as NSError
                    rlog.error("Voice tool \(name) FAILED args=\(arguments.prefix(300)) error=\(message) domain=\(nsError.domain) code=\(nsError.code)", category: "Session")
                    self.realtimeCompletedToolCalls.append(.init(
                        id: callID, name: name, arguments: arguments,
                        result: message, succeeded: false
                    ))
                    appendTimeline(kind: .tool, text: "Tool `\(name)` failed: \(message)")
                    try? await bridge.sendToolOutput(callID: callID, output: #"{"error":"\#(message.replacingOccurrences(of: "\"", with: "\\\""))"}"#)
                }
            }
            toolTasks.append(toolTask)
        case .disconnected:
            // Bridge already drives status messages and reconnect attempts; on
            // the session side just reflect that the live runtime is no longer
            // attached so the plan/strip UI stops claiming it is. The carried
            // `lastError` is consumed by the bridge for retry classification —
            // here we only need the lifecycle hook.
            session.mode = .ready
            refreshPlan(connect: .active, capture: .queued, tool: .queued, answer: .queued)
        case .error(let text):
            handle(errorMessage: text)
        }
    }

    private func handle(error: Error) {
        rlog.error("Session error: \(error.localizedDescription) (type=\(type(of: error)))", category: "Session")
        handle(errorMessage: error.localizedDescription)
    }

    private func handle(errorMessage: String) {
        // Triage common failures so the status line tells the user what to do
        // instead of dumping a raw NSError or HTTP body. Timeline still records
        // the original message for diagnostic purposes.
        let hint = OpenRockyVoiceErrorTriage.hint(for: errorMessage)
        statusText = hint ?? errorMessage
        session.mode = .ready
        refreshPlan(connect: .active, capture: .queued, tool: .queued, answer: .queued)
        appendTimeline(kind: .system, text: errorMessage)
    }

    private func appendTimeline(kind: TimelineKind, text: String) {
        session.timeline.append(
            TimelineEntry(
                kind: kind,
                time: Date().formatted(date: .omitted, time: .shortened),
                text: text
            )
        )
        // Keep enough recent history that one delegate-task (which can fire ~6–15
        // tool events) doesn't immediately push the surrounding speech/result
        // entries off the buffer.
        if session.timeline.count > 32 {
            session.timeline.removeFirst(session.timeline.count - 32)
        }
    }

    /// Visible to tests so they can drive the timeline-rendering branch
    /// without instantiating the toolbox or a real subagent loop. Updates
    /// three things in lockstep: the rolling timeline, the single-line
    /// statusText, and the structured `liveDelegateProgress` snapshot the
    /// voice overlay renders.
    func handleSubagentEvent(_ event: OpenRockySubagentEvent) {
        switch event {
        case .planStarted(let count, let description):
            let label = count == 1 ? "subtask" : "subtasks"
            statusText = "Delegating \(count) \(label)..."
            appendTimeline(kind: .tool, text: "Delegated task → \(count) \(label): \(description.prefix(120))")
            liveProgressClearTask?.cancel()
            liveProgressClearTask = nil
            liveDelegateProgress = OpenRockyDelegateProgress(taskDescription: description)

        case .subtaskStarted(let taskID, let index, let total, let description):
            statusText = "Subtask \(index + 1)/\(total) starting..."
            appendTimeline(kind: .tool, text: "▸ Subtask \(index + 1)/\(total): \(description.prefix(120))")
            mutateProgress { progress in
                let subtask = OpenRockyDelegateProgress.Subtask(
                    id: taskID,
                    index: index,
                    totalCount: total,
                    description: description,
                    status: .running,
                    toolEvents: []
                )
                progress.subtasks.append(subtask)
            }

        case .subtaskThinking(let taskID, let turn):
            // Activity ping while the chat model writes/reasons before any
            // tool call lands. Status only — no timeline entry, since multiple
            // turns would otherwise crowd the buffer.
            statusText = turn == 0 ? "Agent is thinking..." : "Agent is thinking (turn \(turn + 1))..."
            mutateSubtask(taskID: taskID) { subtask in
                subtask.status = .thinking(turn: turn)
            }

        case .toolStarted(let taskID, let name, let isSkill, let arguments):
            let prefix = isSkill ? "skill" : "tool"
            statusText = "Calling \(prefix) `\(name)`..."
            let argsPreview = OpenRockySubagentRuntime.previewArguments(arguments)
            let argsPart = argsPreview.isEmpty ? "" : " \(argsPreview)"
            appendTimeline(kind: .tool, text: "  • [\(prefix)] \(name)\(argsPart)")
            mutateSubtask(taskID: taskID) { subtask in
                subtask.toolEvents.append(.init(
                    id: UUID(),
                    name: name,
                    isSkill: isSkill,
                    argsPreview: argsPreview,
                    status: .running
                ))
                if subtask.toolEvents.count > OpenRockyDelegateProgress.maxToolEventsPerSubtask {
                    subtask.toolEvents.removeFirst(subtask.toolEvents.count - OpenRockyDelegateProgress.maxToolEventsPerSubtask)
                }
            }

        case .toolCompleted(let taskID, let name, let isSkill, let succeeded, let arguments, let result, let elapsed):
            let prefix = isSkill ? "skill" : "tool"
            let mark = succeeded ? "✓" : "✗"
            let elapsedLabel = String(format: "%.1fs", elapsed)
            statusText = "\(mark) \(prefix) `\(name)` (\(elapsedLabel))"
            let kind: TimelineKind = succeeded ? .tool : .system
            let resultPreview = OpenRockySubagentRuntime.previewResult(result)
            appendTimeline(kind: kind, text: "    \(mark) \(name) (\(elapsedLabel)) → \(resultPreview)")
            // Capture full args/result for chat-history persistence — the
            // chat detail view will render each as a ToolCallContentPart.
            subagentCompletedToolCalls.append(.init(
                id: "\(taskID)-\(subagentCompletedToolCalls.count)",
                name: name,
                arguments: arguments,
                result: result,
                succeeded: succeeded
            ))
            mutateSubtask(taskID: taskID) { subtask in
                if let lastIdx = subtask.toolEvents.lastIndex(where: { $0.name == name && $0.status == .running }) {
                    subtask.toolEvents[lastIdx].status = .completed(succeeded: succeeded)
                    subtask.toolEvents[lastIdx].resultPreview = resultPreview
                    subtask.toolEvents[lastIdx].elapsedSeconds = elapsed
                }
            }

        case .subtaskCompleted(let taskID, let succeeded, let summary, let elapsed):
            let mark = succeeded ? "✓" : "✗"
            let elapsedLabel = String(format: "%.1fs", elapsed)
            appendTimeline(kind: succeeded ? .result : .system, text: "\(mark) Subtask done (\(elapsedLabel)): \(summary.prefix(120))")
            mutateSubtask(taskID: taskID) { subtask in
                subtask.status = .completed
                subtask.summary = summary
                subtask.succeeded = succeeded
                subtask.elapsedSeconds = elapsed
            }

        case .planCompleted(let elapsed, let succeededCount, let totalCount):
            let elapsedLabel = String(format: "%.1fs", elapsed)
            statusText = "Delegate-task finished (\(succeededCount)/\(totalCount), \(elapsedLabel))."
            appendTimeline(kind: .result, text: "Delegate-task complete: \(succeededCount)/\(totalCount) subtasks succeeded in \(elapsedLabel)")
            mutateProgress { progress in
                progress.finishedAt = Date()
                progress.planSucceededCount = succeededCount
                progress.planTotalCount = totalCount
            }
            scheduleLiveProgressClear(after: 4)
        }
    }

    private func mutateProgress(_ change: (inout OpenRockyDelegateProgress) -> Void) {
        guard var progress = liveDelegateProgress else { return }
        change(&progress)
        liveDelegateProgress = progress
    }

    private func mutateSubtask(taskID: String, change: (inout OpenRockyDelegateProgress.Subtask) -> Void) {
        guard var progress = liveDelegateProgress else { return }
        guard let idx = progress.subtasks.firstIndex(where: { $0.id == taskID }) else { return }
        change(&progress.subtasks[idx])
        liveDelegateProgress = progress
    }

    /// Schedule the live progress panel to clear after the user has had a
    /// moment to see the final summary. A new delegate-task will cancel this
    /// in `planStarted` so the panel jumps straight to the new run instead of
    /// briefly going blank.
    private func scheduleLiveProgressClear(after seconds: TimeInterval) {
        liveProgressClearTask?.cancel()
        liveProgressClearTask = Task { [weak self] in
            try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            guard let self, !Task.isCancelled else { return }
            self.liveDelegateProgress = nil
        }
    }

    private func resetAssistantTurn() {
        toolTasks.forEach { $0.cancel() }
        toolTasks.removeAll()
        assistantTranscriptBuffer = ""
        session.assistantReply = "OpenRocky is waiting for a model reply."
        lastToolName = nil
        realtimeCompletedToolCalls = []
        subagentCompletedToolCalls = []
    }

    private func saveVoiceTurnToConversation(userText: String, assistantText: String) {
        guard !conversationID.isEmpty, !userText.isEmpty, !assistantText.isEmpty else { return }
        let userMsg = ConversationMessage(conversationID: conversationID, role: .user)
        userMsg.parts = [.text(TextContentPart(text: userText))]

        var messages: [ConversationMessage] = [userMsg]

        // Persist tool calls executed by the realtime voice provider AND any
        // sub-tool calls the chat sub-agent made inside delegate-task. The
        // chat detail view renders one ToolCallContentPart per call so the
        // user can see the full tool tree (each MCP call, each skill, each
        // apple-tool) instead of only seeing "delegate-task" at the top
        // level with everything buried in its JSON result string.
        let combinedToolCalls = realtimeCompletedToolCalls + subagentCompletedToolCalls
        if !combinedToolCalls.isEmpty {
            let allToolDefs = OpenRockyBuiltInToolStore.shared.tools
            let toolMsg = ConversationMessage(conversationID: conversationID, role: .assistant)
            toolMsg.parts = combinedToolCalls.map { tc in
                let def = allToolDefs.first { $0.id == tc.name }
                let displayName: String
                let icon: String?
                if let skill = OpenRockyCustomSkillStore.shared.skill(forToolName: tc.name) {
                    displayName = "Skill: \(skill.name)"
                    icon = "sparkles"
                } else if tc.name.hasPrefix("mcp-") {
                    // MCP tools come in as `mcp-{label}-{tool}`. Show the
                    // tool name minus the namespace; tag the icon so the
                    // chat row stands out from the local-tool fleet.
                    let displayed = OpenRockyMCPServer.parseNamespacedToolName(tc.name).map { "MCP: \($0.toolName)" } ?? tc.name
                    displayName = displayed
                    icon = "network"
                } else {
                    displayName = def?.displayName ?? tc.name
                    icon = def?.icon
                }
                return .toolCall(ToolCallContentPart(
                    id: tc.id,
                    toolName: displayName,
                    apiName: tc.name,
                    toolIcon: icon,
                    parameters: tc.arguments,
                    state: tc.succeeded ? .succeeded : .failed,
                    result: tc.result
                ))
            }
            messages.append(toolMsg)
        }

        let assistantMsg = ConversationMessage(conversationID: conversationID, role: .assistant)
        assistantMsg.parts = [.text(TextContentPart(text: assistantText))]
        messages.append(assistantMsg)

        storage.save(messages)
        // Tool calls belong to this turn only — clear so the next turn doesn't
        // re-attach them when its assistantTranscriptFinal fires.
        realtimeCompletedToolCalls.removeAll()
        subagentCompletedToolCalls.removeAll()
        generateTitleIfNeeded()
    }

    private func generateTitleIfNeeded() {
        guard let session = ConversationSessionManager.shared.existingSession(for: conversationID) else { return }
        session.refreshContentsFromDatabase(scrolling: true)
        Task { await session.updateTitle() }
    }

    private func refreshPlan(
        connect: StepState,
        capture: StepState,
        tool: StepState,
        answer: StepState,
        toolDetail: String? = nil
    ) {
        session.plan = [
            PlanStep(
                title: "Connect live runtime",
                detail: "Attach one realtime session that both voice and text can reuse.",
                state: connect
            ),
            PlanStep(
                title: "Capture user intent",
                detail: "Stream microphone audio or accept the home text composer through the same session state.",
                state: capture
            ),
            PlanStep(
                title: "Run first Apple tools",
                detail: toolDetail ?? "Use `apple-location`, `apple-weather`, or `apple-alarm` only when they materially help finish the task.",
                state: tool
            ),
            PlanStep(
                title: "Reply with transcript and audio",
                detail: "Keep the answer visible on the home surface while the realtime model speaks back.",
                state: answer
            )
        ]
    }
}
