//
// 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 Foundation
import Testing
@testable import OpenRocky
import LanguageModelChatUI
import SwiftOpenAI

// MARK: - Shared Tool Name Constants

/// All tool names registered in realtimeToolDefinitions / chatToolDefinitions.
/// Single source of truth — all tests reference this instead of duplicating the list.
private let kRealtimeToolNames: Set<String> = [
    "apple-location", "apple-geocode", "weather", "apple-alarm",
    "memory_get", "memory_write",
    "apple-health-summary", "apple-health-metric",
    "shell-execute", "python-execute", "ffmpeg-execute",
    "browser-open", "browser-cookies", "browser-read",
    "oauth-authenticate", "crypto",
    "file-read", "file-write", "file-pick",
    "todo", "web-search",
    "apple-calendar-list", "apple-calendar-create",
    "apple-reminder-list", "apple-reminder-create",
    "notification-schedule", "open-url",
    "nearby-search", "apple-contacts-search",
    "camera-capture", "photo-pick",
    "delegate-task"
]

/// Tools registered only via OpenRockyToolProvider (conditionally enabled, not in realtime definitions).
private let kConditionalToolNames: Set<String> = [
    "app-exit", "email-send"
]

/// Every tool that has a dispatch case in _execute.
private let kAllDispatchedToolNames: Set<String> = kRealtimeToolNames.union(kConditionalToolNames)

/// Minimal valid JSON arguments for each tool, enough to pass decode and hit the handler.
private let kMinimalArgs: [String: String] = [
    "apple-location": "{}",
    "apple-geocode": #"{"address":"Tokyo"}"#,
    "weather": "{}",
    "apple-alarm": #"{"scheduled_at":"2099-01-01T07:00:00"}"#,
    "memory_get": #"{"key":"__dispatch_test__"}"#,
    "memory_write": #"{"key":"__dispatch_test__","value":"x"}"#,
    "apple-health-summary": #"{"date":"2025-01-01"}"#,
    "apple-health-metric": #"{"metric":"steps","start_date":"2025-01-01","end_date":"2025-01-02"}"#,
    "shell-execute": #"{"command":"echo hi"}"#,
    "python-execute": #"{"code":"print(1)"}"#,
    "ffmpeg-execute": #"{"args":"-version"}"#,
    "browser-open": #"{"url":"https://example.com"}"#,
    "browser-cookies": #"{"domain":"example.com"}"#,
    "browser-read": #"{"url":"https://example.com"}"#,
    "oauth-authenticate": #"{"auth_url":"https://example.com","callback_scheme":"rocky"}"#,
    "crypto": #"{"operation":"sha256","data":"test"}"#,
    "file-read": #"{"path":"__nonexistent__.txt"}"#,
    "file-write": #"{"path":"__dispatch_test__.txt","content":"test"}"#,
    "todo": #"{"action":"list"}"#,
    "web-search": #"{"query":"test"}"#,
    "apple-calendar-list": #"{"start_date":"2025-01-01","end_date":"2025-01-02"}"#,
    "apple-calendar-create": #"{"title":"test","start_date":"2099-01-01T10:00:00"}"#,
    "apple-reminder-list": "{}",
    "apple-reminder-create": #"{"title":"test"}"#,
    "notification-schedule": #"{"title":"test"}"#,
    "open-url": #"{"url":"https://example.com"}"#,
    "nearby-search": #"{"query":"coffee"}"#,
    "apple-contacts-search": #"{"query":"__nobody__"}"#,
    "camera-capture": "{}",
    "photo-pick": "{}",
    "file-pick": "{}",
    "app-exit": #"{"farewell_message":"test"}"#,
    "email-send": #"{"to":"test@test.com","subject":"test","body":"test"}"#,
    "delegate-task": #"{"task":"test task"}"#
]

/// Lightweight builder for tests that need a populated `ConversationMessage`
/// without going through the storage layer.
private func makeTextMessage(cid: String, role: MessageRole, text: String) -> ConversationMessage {
    let msg = ConversationMessage(conversationID: cid, role: role)
    msg.parts = [.text(TextContentPart(text: text))]
    return msg
}

@MainActor
struct OpenRockyTests {

    @Test func sampleSessionHasVisibleExecutionPlan() async throws {
        let session = OpenRockyPreviewSession.sample

        #expect(session.plan.count >= 4)
        #expect(session.completedCount == 1)
        #expect(session.timeline.count >= 4)
        #expect(session.artifactCount == 3)
    }

    @Test func sessionModeCyclesAcrossPrimaryStates() async throws {
        #expect(SessionMode.listening.next == .planning)
        #expect(SessionMode.planning.next == .executing)
        #expect(SessionMode.executing.next == .ready)
        #expect(SessionMode.ready.next == .listening)
    }

    @Test func capabilityInventoryKeepsLayerNamesDistinct() async throws {
        let groups = OpenRockyPreviewSession.sample.capabilityGroups

        #expect(groups.count == 3)
        #expect(groups.map(\.title).contains("iOS Native Bridge"))
        #expect(groups.map(\.title).contains("AI Tool Layer"))
        #expect(groups.map(\.title).contains("Platform Integrations"))
    }

    @Test func providerInventoryIncludesConfiguredBackends() async throws {
        #expect(OpenRockyProviderKind.allCases.contains(.openAI))
        #expect(OpenRockyProviderKind.allCases.contains(.azureOpenAI))
        #expect(OpenRockyProviderKind.allCases.contains(.anthropic))
        #expect(OpenRockyProviderKind.allCases.contains(.gemini))
        #expect(OpenRockyProviderKind.allCases.contains(.groq))
        #expect(OpenRockyProviderKind.allCases.contains(.xAI))
        #expect(OpenRockyProviderKind.allCases.contains(.openRouter))
        #expect(OpenRockyProviderKind.allCases.contains(.deepSeek))
        #expect(OpenRockyProviderKind.allCases.contains(.aiProxy))
        #expect(OpenRockyProviderKind.allCases.contains(.bailian))
        #expect(OpenRockyProviderKind.openAI.defaultModel == "gpt-5")
    }

    @Test func realtimeProviderInventoryIsOpenAIOnly() async throws {
        // Post-2026-04-25: OpenAI Realtime is the sole voice provider. Classic
        // STT/TTS and GLM realtime were removed. Default model bumped to
        // gpt-realtime-2 on 2026-05-07 (release day) — gpt-realtime/gpt-realtime-mini
        // were dropped from the suggested list at the same time.
        #expect(OpenRockyRealtimeProviderKind.allCases == [.openAI])
        #expect(OpenRockyRealtimeProviderKind.openAI.defaultModel == "gpt-realtime-2")
        #expect(OpenRockyRealtimeProviderKind.openAI.suggestedModels == ["gpt-realtime-2"])
    }

    @Test func realtimeAdvancedSettingsDefaultToReasoningLow() async throws {
        // Reasoning effort surfaces gpt-realtime-2's GPT-5-class reasoning knob.
        // OpenAI recommends `low` as the production-voice starting point — higher
        // levels add latency on every turn.
        let defaults = OpenRockyRealtimeAdvancedSettings.default
        #expect(defaults.reasoningEffort == .low)
        #expect(defaults.realtimeModel == "gpt-realtime-2")
    }

    @Test func realtimeSessionConfigEncodesGASchema() async throws {
        // Pin the GA-shape encoding so a future SwiftOpenAI re-pull (or a
        // careless edit) can't silently revert us to the legacy flat fields.
        // The test asserts both presence of the new nested keys and absence
        // of the old top-level ones.
        let config = OpenAIRealtimeSessionConfiguration(
            audio: .init(
                input: .init(
                    format: .pcm24k,
                    transcription: .init(model: "gpt-4o-mini-transcribe", language: "zh"),
                    turnDetection: .init(type: .semanticVAD(eagerness: .medium))
                ),
                output: .init(format: .pcm24k, voice: "alloy", speed: 1.0)
            ),
            instructions: "be brief",
            maxOutputTokens: .int(1024),
            outputModalities: [.audio],
            reasoning: .init(effort: .low),
            tools: nil,
            toolChoice: .auto
        )
        let data = try JSONEncoder().encode(config)
        let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]

        // GA discriminator + nested audio block.
        #expect(json["type"] as? String == "realtime")
        #expect(json["output_modalities"] as? [String] == ["audio"])
        #expect(json["max_output_tokens"] as? Int == 1024)
        let audio = try #require(json["audio"] as? [String: Any])
        let input = try #require(audio["input"] as? [String: Any])
        let output = try #require(audio["output"] as? [String: Any])
        let inputFormat = try #require(input["format"] as? [String: Any])
        #expect(inputFormat["type"] as? String == "audio/pcm")
        #expect(inputFormat["rate"] as? Int == 24000)
        #expect((input["transcription"] as? [String: Any])?["model"] as? String == "gpt-4o-mini-transcribe")
        #expect((input["turn_detection"] as? [String: Any])?["type"] as? String == "semantic_vad")
        #expect(output["voice"] as? String == "alloy")
        #expect((json["reasoning"] as? [String: Any])?["effort"] as? String == "low")

        // Legacy flat keys must NOT appear — if any of these come back the
        // schema regressed.
        #expect(json["modalities"] == nil)
        #expect(json["voice"] == nil)
        #expect(json["speed"] == nil)
        #expect(json["input_audio_format"] == nil)
        #expect(json["output_audio_format"] == nil)
        #expect(json["input_audio_transcription"] == nil)
        #expect(json["max_response_output_tokens"] == nil)
        #expect(json["turn_detection"] == nil)
        // GA also dropped the top-level `temperature` knob — reasoning models
        // handle randomness internally. Asserting absence is the regression
        // guard against re-introducing it.
        #expect(json["temperature"] == nil)
    }

    @Test func realtimeAdvancedSettingsDecodeWithoutReasoningEffortField() async throws {
        // Old persisted settings (pre-reasoningEffort) must still decode so
        // existing users don't lose their tuning on upgrade. Missing key →
        // falls back to .low.
        // Round-trip the current default (which has reasoningEffort) and then strip
        // the new key out of the JSON. Decoding the trimmed JSON should still
        // succeed and yield .low. This shape-checks our `decodeIfPresent` migration
        // without hard-coding the auto-synthesized enum encoding for TurnDetection.
        var encoded = try JSONEncoder().encode(OpenRockyRealtimeAdvancedSettings.default)
        var dict = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
        dict.removeValue(forKey: "reasoningEffort")
        encoded = try JSONSerialization.data(withJSONObject: dict)
        let decoded = try JSONDecoder().decode(OpenRockyRealtimeAdvancedSettings.self, from: encoded)
        #expect(decoded.reasoningEffort == .low)
    }

    @Test func providerConfigurationReflectsConnectionStateInIdentity() async throws {
        let disconnected = OpenRockyProviderConfiguration(
            provider: .openAI,
            modelID: "gpt-5.2-codex",
            credential: nil
        )
        let connected = OpenRockyProviderConfiguration(
            provider: .openAI,
            modelID: "gpt-5.2-codex",
            credential: "sk-test-1234"
        )

        #expect(disconnected.identity.contains("disconnected"))
        #expect(connected.identity.contains("connected"))
        #expect(connected.maskedCredential.contains("••••"))
    }

    @Test func azureAndAIProxyRequireProviderSpecificFields() async throws {
        let azure = OpenRockyProviderConfiguration(
            provider: .azureOpenAI,
            modelID: "gpt-5-codex",
            credential: "azure-key",
            azureResourceName: "rocky-dev",
            azureAPIVersion: "2024-10-21"
        )
        let incompleteAzure = OpenRockyProviderConfiguration(
            provider: .azureOpenAI,
            modelID: "gpt-5-codex",
            credential: "azure-key"
        )
        let aiProxy = OpenRockyProviderConfiguration(
            provider: .aiProxy,
            modelID: "gpt-5.2-codex",
            credential: "pk_live_1234",
            aiProxyServiceURL: "https://api.aiproxy.pro/example"
        )

        #expect(azure.normalized().isConfigured)
        #expect(incompleteAzure.normalized().isConfigured == false)
        #expect(aiProxy.normalized().isConfigured)
    }

    @Test func realtimeProviderConfigurationReflectsCredentialState() async throws {
        let disconnected = OpenRockyRealtimeProviderConfiguration(
            provider: .openAI,
            modelID: "gpt-realtime",
            credential: nil
        )
        let connected = OpenRockyRealtimeProviderConfiguration(
            provider: .openAI,
            modelID: "gpt-realtime",
            credential: "sk-openai-test"
        )

        #expect(disconnected.identity.contains("disconnected"))
        #expect(connected.identity.contains("connected"))
        #expect(connected.normalized().isConfigured)
        #expect(connected.maskedCredential.contains("••••"))
    }

    // MARK: - Toolbox Registration Tests

    @Test func realtimeToolDefinitionsContainsAllTools() async throws {
        let tools = OpenRockyToolbox.realtimeToolDefinitions()
        var names: [String] = []
        for tool in tools {
            if case let .function(def) = tool {
                names.append(def.name)
            }
        }

        for name in kRealtimeToolNames {
            #expect(names.contains(name), "Missing realtime tool: \(name)")
        }
        #expect(names.count == kRealtimeToolNames.count, "Expected \(kRealtimeToolNames.count) tools, got \(names.count)")
    }

    @Test func chatToolDefinitionsContainsAllTools() async throws {
        let tools = OpenRockyToolbox.chatToolDefinitions()
        let names = tools.map { $0.function.name }

        for name in kRealtimeToolNames {
            #expect(names.contains(name), "Missing chat tool: \(name)")
        }
        #expect(names.count == kRealtimeToolNames.count, "Expected \(kRealtimeToolNames.count) chat tools, got \(names.count)")
    }

    @Test func realtimeAndChatToolDefinitionsAreInSync() async throws {
        let realtimeTools = OpenRockyToolbox.realtimeToolDefinitions()
        let chatTools = OpenRockyToolbox.chatToolDefinitions()

        var realtimeNames = Set<String>()
        for tool in realtimeTools {
            if case let .function(def) = tool {
                realtimeNames.insert(def.name)
            }
        }
        let chatNames = Set(chatTools.map { $0.function.name })

        #expect(realtimeNames == chatNames, "Realtime and chat tool sets differ: realtime-only=\(realtimeNames.subtracting(chatNames)), chat-only=\(chatNames.subtracting(realtimeNames))")
    }

    @Test func skillStoreCoversAllTools() async throws {
        let store = OpenRockyBuiltInToolStore()
        let toolIDs = Set(store.tools.map(\.id))

        let realtimeTools = OpenRockyToolbox.realtimeToolDefinitions()
        var toolNames = Set<String>()
        for tool in realtimeTools {
            if case let .function(def) = tool {
                toolNames.insert(def.name)
            }
        }

        for name in toolNames {
            #expect(toolIDs.contains(name), "Tool '\(name)' not in ToolStore")
        }
    }

    @Test func toolStoreGroupsAreNonEmpty() async throws {
        let store = OpenRockyBuiltInToolStore()
        let groups = store.toolsByGroup()

        #expect(groups.count >= 6, "Expected at least 6 tool groups, got \(groups.count)")
        for group in groups {
            #expect(!group.tools.isEmpty, "Group '\(group.group.rawValue)' has no tools")
        }
    }

    @Test func allToolsRegistered() async throws {
        let store = OpenRockyBuiltInToolStore()
        #expect(store.tools.count >= 31, "Should have at least 31 built-in tools")
    }

    @Test func characterPromptContainsAllToolNames() async throws {
        let store = OpenRockyCharacterStore()
        let prompt = store.systemPrompt

        for name in kRealtimeToolNames {
            #expect(prompt.contains(name), "Character prompt missing tool: \(name)")
        }
    }

    // MARK: - Dispatch Tests (Pure Logic Tools)

    /// Verify crypto tool dispatches and computes correct SHA256
    @Test func dispatchCryptoSHA256() async throws {
        let toolbox = OpenRockyToolbox()
        let result = try await toolbox.execute(
            name: "crypto",
            arguments: #"{"operation":"sha256","data":"hello"}"#
        )
        #expect(result.contains("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"))
    }

    /// Verify crypto tool dispatches and computes correct MD5
    @Test func dispatchCryptoMD5() async throws {
        let toolbox = OpenRockyToolbox()
        let result = try await toolbox.execute(
            name: "crypto",
            arguments: #"{"operation":"md5","data":"hello"}"#
        )
        #expect(result.contains("5d41402abc4b2a76b9719d911017c592"))
    }

    /// Verify crypto tool dispatches base64 encode
    @Test func dispatchCryptoBase64Encode() async throws {
        let toolbox = OpenRockyToolbox()
        let result = try await toolbox.execute(
            name: "crypto",
            arguments: #"{"operation":"base64_encode","data":"hello"}"#
        )
        #expect(result.contains("aGVsbG8="))
    }

    /// Verify crypto tool dispatches base64 decode
    @Test func dispatchCryptoBase64Decode() async throws {
        let toolbox = OpenRockyToolbox()
        let result = try await toolbox.execute(
            name: "crypto",
            arguments: #"{"operation":"base64_decode","data":"aGVsbG8="}"#
        )
        #expect(result.contains("hello"))
    }

    /// Verify todo tool dispatches add + list + delete cycle
    @Test func dispatchTodoLifecycle() async throws {
        let toolbox = OpenRockyToolbox()

        // Add
        let addResult = try await toolbox.execute(
            name: "todo",
            arguments: #"{"action":"add","title":"dispatch test item"}"#
        )
        #expect(addResult.contains("dispatch test item"))
        #expect(addResult.contains("\"created\":true") || addResult.contains("\"created\" : true"))

        // List
        let listResult = try await toolbox.execute(
            name: "todo",
            arguments: #"{"action":"list"}"#
        )
        #expect(listResult.contains("dispatch test item"))

        // Extract id from addResult for cleanup
        if let idRange = addResult.range(of: #"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}"#, options: [.regularExpression, .caseInsensitive]) {
            let id = String(addResult[idRange])
            let deleteResult = try await toolbox.execute(
                name: "todo",
                arguments: #"{"action":"delete","id":"\#(id)"}"#
            )
            #expect(deleteResult.contains("\"deleted\":true") || deleteResult.contains("\"deleted\" : true"))
        }
    }

    /// Verify memory_write + memory_get round-trip
    @Test func dispatchMemoryRoundTrip() async throws {
        let toolbox = OpenRockyToolbox()
        let testKey = "dispatch_test_\(UUID().uuidString.prefix(8))"

        let writeResult = try await toolbox.execute(
            name: "memory_write",
            arguments: #"{"key":"\#(testKey)","value":"test_value_42"}"#
        )
        #expect(writeResult.contains("\"stored\":true") || writeResult.contains("\"stored\" : true"))

        let getResult = try await toolbox.execute(
            name: "memory_get",
            arguments: #"{"key":"\#(testKey)"}"#
        )
        #expect(getResult.contains("test_value_42"))
    }

    /// Verify unsupported tool name throws error
    @Test func dispatchUnknownToolThrows() async throws {
        let toolbox = OpenRockyToolbox()
        do {
            _ = try await toolbox.execute(name: "nonexistent-tool", arguments: "{}")
            #expect(Bool(false), "Should have thrown")
        } catch {
            #expect(error.localizedDescription.contains("nonexistent-tool"))
        }
    }

    /// Verify all tool names (realtime + conditional) route without "unsupported tool" error.
    /// Tools may fail due to missing permissions/hardware, but must not hit the default case.
    @Test func dispatchAllToolsRoute() async throws {
        let toolbox = OpenRockyToolbox()

        for name in kAllDispatchedToolNames {
            let args = kMinimalArgs[name] ?? "{}"
            do {
                _ = try await toolbox.execute(name: name, arguments: args)
            } catch {
                // Acceptable: permission denied, hardware unavailable, SMTP not configured, etc.
                // NOT acceptable: "does not recognize tool" (means routing is broken)
                let msg = error.localizedDescription
                #expect(
                    !msg.contains("does not recognize tool"),
                    "Tool '\(name)' not routed in _execute switch"
                )
            }
        }
    }

    /// Verify kMinimalArgs covers every dispatched tool (no tool left without test args)
    @Test func minimalArgsCoverAllTools() async throws {
        for name in kAllDispatchedToolNames {
            #expect(kMinimalArgs[name] != nil, "Tool '\(name)' missing from kMinimalArgs")
        }
    }

    // MARK: - Skill Dispatch Tests

    /// Verify all 10 built-in skills are loaded
    @Test func builtInSkillsCount() async throws {
        #expect(OpenRockyBuiltInSkills.all.count == 10)
    }

    /// Verify each built-in skill has non-empty name, description, trigger, and prompt
    @Test func builtInSkillsHaveContent() async throws {
        for skill in OpenRockyBuiltInSkills.all {
            #expect(!skill.name.isEmpty, "Skill has empty name")
            #expect(!skill.description.isEmpty, "Skill '\(skill.name)' has empty description")
            #expect(!skill.trigger.isEmpty, "Skill '\(skill.name)' has empty trigger")
            #expect(!skill.prompt.isEmpty, "Skill '\(skill.name)' has empty prompt")
        }
    }

    /// Verify built-in skill names are unique
    @Test func builtInSkillNamesUnique() async throws {
        let names = OpenRockyBuiltInSkills.all.map(\.name)
        #expect(Set(names).count == names.count, "Duplicate skill names found")
    }

    // MARK: - Subagent Event → Timeline Rendering

    /// A canonical delegate-task progress sequence renders one timeline entry per
    /// structured event (except `subtaskThinking`, which is status-only) and
    /// updates statusText to reflect the current step. Guards against the
    /// pre-aa81972 regression where a long-running delegate-task surfaced as a
    /// single dead "Agent: foo..." line.
    @Test func subagentEventsAppendTimelineAndStatus() async throws {
        let runtime = OpenRockySessionRuntime()
        let baselineTimelineCount = runtime.session.timeline.count

        runtime.handleSubagentEvent(.planStarted(subtaskCount: 2, taskDescription: "test plan"))
        #expect(runtime.statusText.contains("Delegating 2 subtasks"))

        runtime.handleSubagentEvent(.subtaskStarted(taskID: "t1", subtaskIndex: 0, totalCount: 2, description: "sub one"))
        runtime.handleSubagentEvent(.subtaskThinking(taskID: "t1", turn: 0))
        #expect(runtime.statusText == "Agent is thinking...")

        runtime.handleSubagentEvent(.toolStarted(taskID: "t1", name: "weather", isSkill: false, arguments: "{}"))
        runtime.handleSubagentEvent(.toolCompleted(taskID: "t1", name: "weather", isSkill: false, succeeded: true, arguments: "{}", result: "sunny", elapsedSeconds: 0.4))
        runtime.handleSubagentEvent(.subtaskCompleted(taskID: "t1", succeeded: true, summary: "ok", elapsedSeconds: 0.5))

        runtime.handleSubagentEvent(.toolStarted(taskID: "t2", name: "skill-foo", isSkill: true, arguments: ""))
        runtime.handleSubagentEvent(.toolCompleted(taskID: "t2", name: "skill-foo", isSkill: true, succeeded: false, arguments: "", result: "boom", elapsedSeconds: 0.2))
        runtime.handleSubagentEvent(.subtaskCompleted(taskID: "t2", succeeded: false, summary: "failed", elapsedSeconds: 0.3))

        runtime.handleSubagentEvent(.planCompleted(elapsedSeconds: 1.0, succeededCount: 1, totalCount: 2))

        // 1 plan + 2 subtaskStarted + 2 toolStarted + 2 toolCompleted + 2 subtaskCompleted + 1 planCompleted = 10
        // (subtaskThinking is intentionally status-only)
        #expect(runtime.session.timeline.count == baselineTimelineCount + 10)
        #expect(runtime.statusText.contains("Delegate-task finished (1/2"))

        // Failure marker appears on the failed tool's timeline entry.
        let failedToolEntry = runtime.session.timeline.first { $0.text.contains("✗ skill-foo") }
        #expect(failedToolEntry != nil, "Failed tool should be marked with ✗ in the timeline")
    }

    // MARK: - Voice Priming (replaying prior conversation into a fresh session)

    /// Empty conversation → empty priming, so a brand new chat boots with the
    /// persona greeting (current behaviour) instead of the silent-resume path.
    @Test func voicePrimingEmptyConversationReturnsNothing() async throws {
        let items = OpenRockyVoicePriming.items(from: [])
        #expect(items.isEmpty)
    }

    /// Plain user/assistant text turns flow through in chronological order with
    /// roles preserved. This is the common case (existing chat → user reopens
    /// voice → model resumes with full context).
    @Test func voicePrimingPreservesOrderAndRole() async throws {
        let cid = "cid-test-1"
        let messages: [ConversationMessage] = [
            makeTextMessage(cid: cid, role: .user, text: "What's the weather?"),
            makeTextMessage(cid: cid, role: .assistant, text: "Sunny and 72."),
            makeTextMessage(cid: cid, role: .user, text: "And tomorrow?")
        ]
        let items = OpenRockyVoicePriming.items(from: messages)
        #expect(items.count == 3)
        #expect(items[0] == .init(role: .user, text: "What's the weather?"))
        #expect(items[1] == .init(role: .assistant, text: "Sunny and 72."))
        #expect(items[2] == .init(role: .user, text: "And tomorrow?"))
    }

    /// Tool-call-only assistant messages (the realtime voice path persists
    /// these as a separate parts-list with no text) carry no replayable signal,
    /// so they're skipped — but the surrounding text turns still come through.
    @Test func voicePrimingDropsToolOnlyMessagesAndKeepsText() async throws {
        let cid = "cid-test-2"
        let userMsg = makeTextMessage(cid: cid, role: .user, text: "Plan my day.")
        let toolCallMsg = ConversationMessage(conversationID: cid, role: .assistant)
        toolCallMsg.parts = [
            .toolCall(ToolCallContentPart(
                id: "tc1", toolName: "Calendar", apiName: "apple-calendar-list",
                parameters: "{}", state: .succeeded, result: "[]"
            ))
        ]
        let assistantMsg = makeTextMessage(cid: cid, role: .assistant, text: "Looks empty — nice.")

        let items = OpenRockyVoicePriming.items(from: [userMsg, toolCallMsg, assistantMsg])
        #expect(items.count == 2)
        #expect(items[0].role == .user)
        #expect(items[1].role == .assistant)
        #expect(items[1].text == "Looks empty — nice.")
    }

    /// A long single message gets clipped with an ellipsis, signalling the
    /// realtime model that the turn was truncated rather than abandoned mid
    /// thought.
    @Test func voicePrimingClipsOversizedMessages() async throws {
        let cid = "cid-test-3"
        let huge = String(repeating: "x", count: 5000)
        let items = OpenRockyVoicePriming.items(from: [
            makeTextMessage(cid: cid, role: .user, text: huge)
        ])
        #expect(items.count == 1)
        #expect(items[0].text.count == OpenRockyVoicePriming.maxPerItemCharacters)
        #expect(items[0].text.hasSuffix("…"))
    }

    /// More than `maxItemCount` candidate turns → only the most recent window
    /// survives, in chronological order. Earlier turns silently drop.
    @Test func voicePrimingKeepsTailWhenOverItemCap() async throws {
        let cid = "cid-test-4"
        let messages: [ConversationMessage] = (0 ..< 30).map { index in
            let role: MessageRole = index.isMultiple(of: 2) ? .user : .assistant
            return makeTextMessage(cid: cid, role: role, text: "turn \(index)")
        }
        let items = OpenRockyVoicePriming.items(from: messages)
        #expect(items.count == OpenRockyVoicePriming.maxItemCount)
        // The last item must be the most recent message in the source list.
        #expect(items.last?.text == "turn 29")
        // The earliest kept must align with the tail window.
        #expect(items.first?.text == "turn \(30 - OpenRockyVoicePriming.maxItemCount)")
    }

    // MARK: - MCP Tool Namespacing

    /// Sanitization keeps the namespaced tool name OpenAI-compatible
    /// (a-z, A-Z, 0-9, _, -, ≤64 chars) even when the server label has
    /// punctuation or uppercase. The label segment is lower-cased — that's
    /// load-bearing for the round-trip lookup in `parseNamespacedToolName`.
    @Test func mcpToolNamingScrubsLabelAndStaysUnder64Chars() async throws {
        let name = OpenRockyMCPServer.sanitizedToolName(
            serverLabel: "GitHub Enterprise!",
            toolName: "list_repositories"
        )
        #expect(name.hasPrefix("mcp-github-enterprise"))
        #expect(name.contains("list_repositories"))
        #expect(name.count <= 64)
    }

    /// `parseNamespacedToolName` is the inverse of the sanitization above —
    /// it has to recover the (label, tool) pair so dispatch can find the
    /// right server. Garbage in must yield nil so the toolbox bubbles
    /// `unsupportedTool` instead of dispatching to the wrong place.
    @Test func mcpNamespacedToolNameRoundTrips() async throws {
        let parsed = OpenRockyMCPServer.parseNamespacedToolName("mcp-github-list_repos")
        #expect(parsed?.sanitizedLabel == "github")
        #expect(parsed?.toolName == "list_repos")

        #expect(OpenRockyMCPServer.parseNamespacedToolName("apple-location") == nil)
        #expect(OpenRockyMCPServer.parseNamespacedToolName("mcp-only") == nil)
        #expect(OpenRockyMCPServer.parseNamespacedToolName("mcp--missing-label") == nil)
    }

    /// Round-trip an arbitrary JSON tree through AnyJSON — the type that
    /// MCP tool-call arguments and inputSchemas pass through. A regression
    /// here would silently drop fields the chat model emitted, leading to
    /// MCP servers receiving malformed calls.
    @Test func mcpAnyJSONRoundTripsHeterogenousTree() async throws {
        let source = #"{"a":1,"b":"two","c":[true,null,3.14],"d":{"nested":[1,2]}}"#
        let data = source.data(using: .utf8)!
        let decoded = try JSONDecoder().decode(AnyJSON.self, from: data)
        let encoded = try JSONEncoder().encode(decoded)
        let redecoded = try JSONDecoder().decode(AnyJSON.self, from: encoded)
        #expect(decoded == redecoded)
    }

    /// `tools/call` results carry an array of typed content blocks. We
    /// concatenate the text blocks and drop the rest — non-text blocks
    /// would just confuse the chat model at this layer. Image / resource
    /// blocks are still in the cache for any future provider that wants
    /// to forward them, but `flattenedText` ignores them.
    @Test func mcpToolsCallFlattenedTextSkipsNonTextBlocks() async throws {
        let json = #"""
        {"content":[
          {"type":"text","text":"Hello"},
          {"type":"image","data":"..."},
          {"type":"text","text":"world"}
        ],"isError":false}
        """#
        let data = json.data(using: .utf8)!
        let result = try JSONDecoder().decode(ToolsCallResult.self, from: data)
        #expect(result.flattenedText() == "Hello\nworld")
        #expect(result.isError == false)
    }

    /// The total-character budget is enforced even when individual messages
    /// fit, so a long history can't blow past the input-token allowance.
    @Test func voicePrimingEnforcesTotalCharacterBudget() async throws {
        let cid = "cid-test-5"
        // Each message is 1000 chars → 5 messages = 5000 chars > 4000 cap.
        let oneK = String(repeating: "a", count: 1000)
        let messages: [ConversationMessage] = (0 ..< 5).map { _ in
            makeTextMessage(cid: cid, role: .user, text: oneK)
        }
        let items = OpenRockyVoicePriming.items(from: messages)
        let total = items.reduce(0) { $0 + $1.text.count }
        #expect(total <= OpenRockyVoicePriming.maxTotalCharacters)
    }

    // MARK: - Live Delegate Progress

    /// planStarted seeds the live progress so the voice overlay knows there's
    /// work in flight. Without this the overlay would stay blank for the
    /// 5–30s a delegate-task can run before the first sub-tool fires.
    @Test func liveDelegateProgressBeginsOnPlanStarted() async throws {
        let runtime = OpenRockySessionRuntime()
        #expect(runtime.liveDelegateProgress == nil)

        runtime.handleSubagentEvent(.planStarted(subtaskCount: 1, taskDescription: "Look up the weather"))
        #expect(runtime.liveDelegateProgress != nil)
        #expect(runtime.liveDelegateProgress?.taskDescription == "Look up the weather")
        #expect(runtime.liveDelegateProgress?.subtasks.isEmpty == true)
        #expect(runtime.liveDelegateProgress?.isFinished == false)
    }

    /// As subagent events arrive, the live snapshot folds them in: subtask
    /// gets created, tool events accumulate, status flips to completed, then
    /// the plan summary lands. The voice overlay re-renders off this state.
    @Test func liveDelegateProgressFoldsToolEventsByTaskID() async throws {
        let runtime = OpenRockySessionRuntime()
        runtime.handleSubagentEvent(.planStarted(subtaskCount: 1, taskDescription: "weather"))
        runtime.handleSubagentEvent(.subtaskStarted(taskID: "t1", subtaskIndex: 0, totalCount: 1, description: "fetch"))
        runtime.handleSubagentEvent(.toolStarted(taskID: "t1", name: "weather", isSkill: false, arguments: "{}"))
        runtime.handleSubagentEvent(.toolCompleted(
            taskID: "t1",
            name: "weather",
            isSkill: false,
            succeeded: true,
            arguments: "{}",
            result: #"{"temp":72}"#,
            elapsedSeconds: 0.4
        ))
        runtime.handleSubagentEvent(.subtaskCompleted(taskID: "t1", succeeded: true, summary: "72F", elapsedSeconds: 0.5))

        let progress = try #require(runtime.liveDelegateProgress)
        #expect(progress.subtasks.count == 1)
        let subtask = progress.subtasks[0]
        #expect(subtask.id == "t1")
        #expect(subtask.status == .completed)
        #expect(subtask.succeeded == true)
        #expect(subtask.toolEvents.count == 1)
        #expect(subtask.toolEvents[0].name == "weather")
        if case let .completed(succeeded) = subtask.toolEvents[0].status {
            #expect(succeeded == true)
        } else {
            Issue.record("expected tool event status to be .completed")
        }
        #expect(subtask.toolEvents[0].elapsedSeconds == 0.4)
    }

    /// Per-subtask tool history is bounded so the overlay panel doesn't grow
    /// unbounded on long sub-agent loops. The full record still lives in
    /// the timeline + chat history.
    @Test func liveDelegateProgressBoundsToolHistoryPerSubtask() async throws {
        let runtime = OpenRockySessionRuntime()
        runtime.handleSubagentEvent(.planStarted(subtaskCount: 1, taskDescription: "deep"))
        runtime.handleSubagentEvent(.subtaskStarted(taskID: "t1", subtaskIndex: 0, totalCount: 1, description: "deep"))
        for index in 0 ..< 12 {
            runtime.handleSubagentEvent(.toolStarted(taskID: "t1", name: "tool-\(index)", isSkill: false, arguments: ""))
        }
        let subtask = try #require(runtime.liveDelegateProgress?.subtasks.first)
        #expect(subtask.toolEvents.count == OpenRockyDelegateProgress.maxToolEventsPerSubtask)
        // Most recent five survive — the first seven were dropped.
        #expect(subtask.toolEvents.first?.name == "tool-7")
        #expect(subtask.toolEvents.last?.name == "tool-11")
    }

    /// Timeline is capped at 32 to keep one delegate-task burst from pushing
    /// surrounding speech off-screen, but anything beyond that should rotate
    /// out the oldest entries — not crash, not unbounded.
    @Test func timelineCapsAtThirtyTwo() async throws {
        let runtime = OpenRockySessionRuntime()

        // Drive enough events to overflow the cap. Each toolStarted appends one entry.
        for index in 0 ..< 60 {
            runtime.handleSubagentEvent(.toolStarted(
                taskID: "t",
                name: "tool-\(index)",
                isSkill: false,
                arguments: ""
            ))
        }

        #expect(runtime.session.timeline.count == 32)
        // 60 inserts + 1 seed → 61 entries before trimming → 29 trimmed off the
        // front → surviving range is [tool-28 .. tool-59]. Older entries
        // (including the seed) are gone.
        #expect(runtime.session.timeline.first?.text.contains("tool-28") == true)
        #expect(runtime.session.timeline.last?.text.contains("tool-59") == true)
    }
}
