import CoreGraphics
import Foundation
import SwiftUI
import Testing
#if targetEnvironment(macCatalyst)
import UIKit
#endif
@testable import Sybil

private struct MockClientCallSnapshot: Sendable {
    var listWorkspaceItems = 0
    var listChats = 0
    var listSearches = 0
    var createChat = 0
    var getChat = 0
    var updateChatTitle = 0
    var suggestChatTitle = 0
    var updateChatStar = 0
    var lastUpdateChatStarID: String?
    var lastUpdateChatStarred: Bool?
    var updateSearchStar = 0
    var getSearch = 0
    var getActiveRuns = 0
    var runCompletionStream = 0
    var attachCompletionStream = 0
    var attachSearchStream = 0
}

private struct ChatCreateCallSnapshot: Sendable {
    var title: String?
    var provider: Provider?
    var model: String?
    var messages: [CompletionRequestMessage]?
}

private struct UnexpectedClientCall: Error {}

private actor MockSybilClient: SybilAPIClienting {
    private let chatsResponse: [ChatSummary]
    private let searchesResponse: [SearchSummary]
    private let workspaceItemsResponse: [WorkspaceItem]
    private let chatDetails: [String: ChatDetail]
    private let searchDetails: [String: SearchDetail]
    private let createChatResponse: ChatSummary?
    private let updateChatTitleResponses: [String: ChatSummary]
    private let suggestChatTitleResponses: [String: ChatSummary]
    private let updateChatStarResponses: [String: ChatSummary]
    private let updateSearchStarResponses: [String: SearchSummary]
    private let activeRunsResponse: ActiveRunsResponse

    private var snapshot = MockClientCallSnapshot()
    private var lastCreateChatCall: ChatCreateCallSnapshot?
    private var lastCompletionStreamBody: CompletionStreamRequest?
    private var completionStreamEvents: [CompletionStreamEvent]?
    private var listChatsDelayNanoseconds: UInt64 = 0
    private var listSearchesDelayNanoseconds: UInt64 = 0
    private var getChatDelayNanoseconds: UInt64 = 0
    private var getSearchDelayNanoseconds: UInt64 = 0
    private var completionStreamNetworkErrorMessage: String?
    private var completionStreamDelayNanoseconds: UInt64 = 0
    private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
    private var completionAttachDelayNanoseconds: UInt64 = 0
    private var searchStreamNetworkErrorMessage: String?
    private var searchStreamDelayNanoseconds: UInt64 = 0
    private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
    private var searchAttachDelayNanoseconds: UInt64 = 0

    init(
        chatsResponse: [ChatSummary] = [],
        searchesResponse: [SearchSummary] = [],
        chatDetails: [String: ChatDetail] = [:],
        searchDetails: [String: SearchDetail] = [:],
        createChatResponse: ChatSummary? = nil,
        updateChatTitleResponses: [String: ChatSummary] = [:],
        suggestChatTitleResponses: [String: ChatSummary] = [:],
        updateChatStarResponses: [String: ChatSummary] = [:],
        updateSearchStarResponses: [String: SearchSummary] = [:],
        activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
        workspaceItemsResponse: [WorkspaceItem]? = nil
    ) {
        self.chatsResponse = chatsResponse
        self.searchesResponse = searchesResponse
        self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
        self.chatDetails = chatDetails
        self.searchDetails = searchDetails
        self.createChatResponse = createChatResponse
        self.updateChatTitleResponses = updateChatTitleResponses
        self.suggestChatTitleResponses = suggestChatTitleResponses
        self.updateChatStarResponses = updateChatStarResponses
        self.updateSearchStarResponses = updateSearchStarResponses
        self.activeRunsResponse = activeRunsResponse
    }

    private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
        (chats.map { WorkspaceItem(chat: $0) } + searches.map { WorkspaceItem(search: $0) }).sorted { $0.updatedAt > $1.updatedAt }
    }

    func currentSnapshot() -> MockClientCallSnapshot {
        snapshot
    }

    func currentCreateChatCall() -> ChatCreateCallSnapshot? {
        lastCreateChatCall
    }

    func currentCompletionStreamBody() -> CompletionStreamRequest? {
        lastCompletionStreamBody
    }

    func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
        completionStreamEvents = events
        completionStreamDelayNanoseconds = delayNanoseconds
    }

    func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
        completionStreamNetworkErrorMessage = message
        completionStreamDelayNanoseconds = delayNanoseconds
    }

    func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
        listChatsDelayNanoseconds = chats
        listSearchesDelayNanoseconds = searches
    }

    func setGetChatDelay(_ delayNanoseconds: UInt64) {
        getChatDelayNanoseconds = delayNanoseconds
    }

    func setGetSearchDelay(_ delayNanoseconds: UInt64) {
        getSearchDelayNanoseconds = delayNanoseconds
    }

    func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
        searchStreamNetworkErrorMessage = message
        searchStreamDelayNanoseconds = delayNanoseconds
    }

    func setCompletionAttachEvents(
        chatID: String,
        events: [CompletionStreamEvent],
        delayNanoseconds: UInt64 = 0
    ) {
        completionAttachEvents[chatID] = events
        completionAttachDelayNanoseconds = delayNanoseconds
    }

    func setSearchAttachEvents(
        searchID: String,
        events: [SearchStreamEvent],
        delayNanoseconds: UInt64 = 0
    ) {
        searchAttachEvents[searchID] = events
        searchAttachDelayNanoseconds = delayNanoseconds
    }

    func verifySession() async throws -> AuthSession {
        AuthSession(authenticated: true, mode: "open")
    }

    func listWorkspaceItems() async throws -> [WorkspaceItem] {
        snapshot.listWorkspaceItems += 1
        let delay = max(listChatsDelayNanoseconds, listSearchesDelayNanoseconds)
        if delay > 0 {
            try await Task.sleep(nanoseconds: delay)
        }
        return workspaceItemsResponse
    }

    func listChats() async throws -> [ChatSummary] {
        snapshot.listChats += 1
        if listChatsDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
        }
        return chatsResponse
    }

    func createChat(
        title: String?,
        provider: Provider?,
        model: String?,
        messages: [CompletionRequestMessage]?
    ) async throws -> ChatSummary {
        snapshot.createChat += 1
        lastCreateChatCall = ChatCreateCallSnapshot(
            title: title,
            provider: provider,
            model: model,
            messages: messages
        )
        if let createChatResponse {
            return createChatResponse
        }
        throw UnexpectedClientCall()
    }

    func getChat(chatID: String) async throws -> ChatDetail {
        snapshot.getChat += 1
        if getChatDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
        }
        guard let detail = chatDetails[chatID] else {
            throw UnexpectedClientCall()
        }
        return detail
    }

    func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
        snapshot.updateChatTitle += 1
        guard let summary = updateChatTitleResponses[chatID] else {
            throw UnexpectedClientCall()
        }
        return summary
    }

    func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
        snapshot.updateChatStar += 1
        snapshot.lastUpdateChatStarID = chatID
        snapshot.lastUpdateChatStarred = starred
        guard let summary = updateChatStarResponses[chatID] else {
            throw UnexpectedClientCall()
        }
        return summary
    }

    func deleteChat(chatID: String) async throws {
        throw UnexpectedClientCall()
    }

    func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
        snapshot.suggestChatTitle += 1
        guard let summary = suggestChatTitleResponses[chatID] else {
            throw UnexpectedClientCall()
        }
        return summary
    }

    func listSearches() async throws -> [SearchSummary] {
        snapshot.listSearches += 1
        if listSearchesDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
        }
        return searchesResponse
    }

    func createSearch(title: String?, query: String?) async throws -> SearchSummary {
        throw UnexpectedClientCall()
    }

    func getSearch(searchID: String) async throws -> SearchDetail {
        snapshot.getSearch += 1
        if getSearchDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
        }
        guard let detail = searchDetails[searchID] else {
            throw UnexpectedClientCall()
        }
        return detail
    }

    func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary {
        throw UnexpectedClientCall()
    }

    func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
        snapshot.updateSearchStar += 1
        guard let summary = updateSearchStarResponses[searchID] else {
            throw UnexpectedClientCall()
        }
        return summary
    }

    func deleteSearch(searchID: String) async throws {
        throw UnexpectedClientCall()
    }

    func listModels() async throws -> ModelCatalogResponse {
        ModelCatalogResponse(providers: [:])
    }

    func getActiveRuns() async throws -> ActiveRunsResponse {
        snapshot.getActiveRuns += 1
        return activeRunsResponse
    }

    func runCompletionStream(
        body: CompletionStreamRequest,
        onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
    ) async throws {
        snapshot.runCompletionStream += 1
        lastCompletionStreamBody = body
        if completionStreamDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
        }
        if let completionStreamNetworkErrorMessage {
            throw APIError.networkError(message: completionStreamNetworkErrorMessage)
        }
        if let completionStreamEvents {
            for event in completionStreamEvents {
                await onEvent(event)
            }
            return
        }
        throw UnexpectedClientCall()
    }

    func attachCompletionStream(
        chatID: String,
        onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
    ) async throws {
        snapshot.attachCompletionStream += 1
        let events = completionAttachEvents[chatID] ?? []
        for event in events {
            await onEvent(event)
        }
        if completionAttachDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds)
        }
    }

    func runSearchStream(
        searchID: String,
        body: SearchRunRequest,
        onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
    ) async throws {
        if searchStreamDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
        }
        if let searchStreamNetworkErrorMessage {
            throw APIError.networkError(message: searchStreamNetworkErrorMessage)
        }
        throw UnexpectedClientCall()
    }

    func attachSearchStream(
        searchID: String,
        onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
    ) async throws {
        snapshot.attachSearchStream += 1
        let events = searchAttachEvents[searchID] ?? []
        for event in events {
            await onEvent(event)
        }
        if searchAttachDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds)
        }
    }
}

@MainActor
private func testSettings(named name: String) -> SybilSettingsStore {
    let defaults = UserDefaults(suiteName: name)!
    defaults.removePersistentDomain(forName: name)
    let settings = SybilSettingsStore(defaults: defaults)
    settings.apiBaseURL = "http://127.0.0.1:8787"
    return settings
}

private func makeChatSummary(id: String, date: Date) -> ChatSummary {
    ChatSummary(
        id: id,
        title: "Chat \(id)",
        createdAt: date,
        updatedAt: date,
        initiatedProvider: .openai,
        initiatedModel: "gpt-4.1-mini",
        lastUsedProvider: .openai,
        lastUsedModel: "gpt-4.1-mini"
    )
}

private func makeChatDetail(id: String, date: Date, body: String) -> ChatDetail {
    ChatDetail(
        id: id,
        title: "Chat \(id)",
        createdAt: date,
        updatedAt: date,
        initiatedProvider: .openai,
        initiatedModel: "gpt-4.1-mini",
        lastUsedProvider: .openai,
        lastUsedModel: "gpt-4.1-mini",
        messages: [
            Message(
                id: "message-\(id)",
                createdAt: date,
                role: .assistant,
                content: body,
                name: nil
            )
        ]
    )
}

private func makeSearchSummary(id: String, date: Date) -> SearchSummary {
    SearchSummary(
        id: id,
        title: "Search \(id)",
        query: "query-\(id)",
        createdAt: date,
        updatedAt: date
    )
}

private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchDetail {
    SearchDetail(
        id: id,
        title: "Search \(id)",
        query: "query-\(id)",
        createdAt: date,
        updatedAt: date,
        requestId: "request-\(id)",
        latencyMs: 42,
        error: nil,
        answerText: answer,
        answerRequestId: "answer-\(id)",
        answerCitations: [],
        answerError: nil,
        results: []
    )
}

private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran a tool") -> Message {
    Message(
        id: id,
        createdAt: date,
        role: .tool,
        content: summary,
        name: "web_search",
        metadata: .object([
            "kind": .string("tool_call"),
            "toolCallId": .string("call-\(id)"),
            "toolName": .string("web_search"),
            "status": .string("completed"),
            "summary": .string(summary),
            "durationMs": .number(120)
        ])
    )
}

@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
    let decoder = JSONDecoder()
    let legacySummary = try decoder.decode(
        ChatSummary.self,
        from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
    )
    let legacyWorkspaceItem = try decoder.decode(
        WorkspaceItem.self,
        from: Data(#"{"type":"chat","id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
    )
    let legacyDetail = try decoder.decode(
        ChatDetail.self,
        from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
    )
    let forkDetail = try decoder.decode(
        ChatDetail.self,
        from: Data(#"{"id":"fork-chat","title":"Fork of Legacy","parentChatId":"root-chat","titleGenerationPending":true,"createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
    )

    #expect(legacySummary.parentChatId == nil)
    #expect(!legacySummary.titleGenerationPending)
    #expect(legacyWorkspaceItem.parentChatId == nil)
    #expect(!legacyWorkspaceItem.titleGenerationPending)
    #expect(legacyDetail.parentChatId == nil)
    #expect(!legacyDetail.titleGenerationPending)
    #expect(forkDetail.parentChatId == "root-chat")
    #expect(forkDetail.titleGenerationPending)

    var fork = legacySummary
    fork.parentChatId = "root-chat"
    fork.titleGenerationPending = true

    let workspaceItem = WorkspaceItem(chat: fork)
    let restoredSummary = try #require(workspaceItem.chatSummary)
    #expect(workspaceItem.parentChatId == "root-chat")
    #expect(workspaceItem.titleGenerationPending)
    #expect(restoredSummary.parentChatId == "root-chat")
    #expect(restoredSummary.titleGenerationPending)
}

@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_000)
    let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
    let toolA = makeToolCallMessage(id: "tool-a", date: date, summary: "Search A")
    let toolB = makeToolCallMessage(id: "tool-b", date: date, summary: "Search B")
    let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)

    let items = buildTranscriptRenderItems(from: [user, toolA, toolB, assistant])

    #expect(items.count == 3)
    guard case let .message(firstMessage) = items[0] else {
        Issue.record("Expected the first item to remain a normal message")
        return
    }
    #expect(firstMessage.id == "user-1")

    guard case let .toolGroup(groupID, groupedMessages) = items[1] else {
        Issue.record("Expected adjacent tool calls to be grouped")
        return
    }
    #expect(groupID == "tool-a")
    #expect(groupedMessages.map(\.id) == ["tool-a", "tool-b"])

    guard case let .message(lastMessage) = items[2] else {
        Issue.record("Expected the assistant response to remain a normal message")
        return
    }
    #expect(lastMessage.id == "assistant-1")
}

@Test func transcriptRenderItemsKeepSingleToolCallsInline() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_000)
    let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
    let tool = makeToolCallMessage(id: "tool-a", date: date)
    let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)

    let items = buildTranscriptRenderItems(from: [user, tool, assistant])

    #expect(items.count == 3)
    guard case let .message(toolMessage) = items[1] else {
        Issue.record("Expected a single tool call to use the existing inline chip")
        return
    }
    #expect(toolMessage.id == "tool-a")
}

@MainActor
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
    let defaults = UserDefaults(suiteName: #function)!
    defaults.removePersistentDomain(forName: #function)

    let settings = SybilSettingsStore(defaults: defaults)
    settings.apiBaseURL = "https://sybil.bajor.cloud/api/"

    #expect(settings.normalizedAPIBaseURL?.absoluteString == "https://sybil.bajor.cloud/api")
}

@MainActor
@Test func normalizedAPIBaseURLTrimsWhitespaceAndTrailingSlashes() async throws {
    let defaults = UserDefaults(suiteName: #function)!
    defaults.removePersistentDomain(forName: #function)

    let settings = SybilSettingsStore(defaults: defaults)
    settings.apiBaseURL = "  http://127.0.0.1:8787///  "

    #expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787")
}

@MainActor
@Test func foregroundListRefreshDoesNotReloadHiddenSelection() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_000)
    let chat = makeChatSummary(id: "chat-1", date: date)
    let search = makeSearchSummary(id: "search-1", date: date)
    let client = MockSybilClient(
        chatsResponse: [chat],
        searchesResponse: [search],
        chatDetails: ["chat-1": makeChatDetail(id: "chat-1", date: date, body: "fresh chat body")]
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-1")

    await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.listWorkspaceItems == 1)
    #expect(snapshot.listChats == 0)
    #expect(snapshot.listSearches == 0)
    #expect(snapshot.getChat == 0)
    #expect(snapshot.getSearch == 0)
    #expect(viewModel.selectedItem == .chat("chat-1"))
}

@MainActor
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_050)
    let chat = makeChatSummary(id: "chat-cancelled", date: date)
    let search = makeSearchSummary(id: "search-cancelled", date: date)
    let client = MockSybilClient(
        chatsResponse: [chat],
        searchesResponse: [search]
    )
    await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false

    let refreshTask = Task {
        await viewModel.refreshSidebarCollectionsFromPullToRefresh()
    }
    try await Task.sleep(nanoseconds: 10_000_000)
    refreshTask.cancel()
    await refreshTask.value

    #expect(viewModel.errorMessage == nil)
    #expect(!viewModel.isLoadingCollections)
    #expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
    #expect(viewModel.searches.map(\.id) == ["search-cancelled"])
}

@MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_100)
    let staleDetail = makeChatDetail(id: "chat-2", date: date, body: "stale transcript")
    let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
    let client = MockSybilClient(chatDetails: ["chat-2": detail])
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-2")
    viewModel.selectedChat = staleDetail

    await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.listWorkspaceItems == 0)
    #expect(snapshot.listChats == 0)
    #expect(snapshot.listSearches == 0)
    #expect(snapshot.getChat == 1)
    #expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
    #expect(viewModel.chatBottomPinRequestID == 0)
}

@MainActor
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_150)
    var original = makeChatSummary(id: "chat-rename", date: date)
    original.parentChatId = "root-chat"
    original.titleGenerationPending = true
    var renamed = original
    renamed.title = "Renamed chat"
    renamed.titleGenerationPending = false
    renamed.updatedAt = date.addingTimeInterval(60)
    var detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
    detail.parentChatId = original.parentChatId
    detail.titleGenerationPending = original.titleGenerationPending
    let client = MockSybilClient(
        chatsResponse: [original],
        updateChatTitleResponses: ["chat-rename": renamed]
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.chats = [original]
    viewModel.workspaceItems = [WorkspaceItem(chat: original)]
    viewModel.selectedItem = .chat("chat-rename")
    viewModel.selectedChat = detail

    await viewModel.renameChat(chatID: "chat-rename", title: " Renamed chat ")

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.updateChatTitle == 1)
    #expect(viewModel.sidebarItems.first?.title == "Renamed chat")
    #expect(viewModel.chats.first?.parentChatId == "root-chat")
    #expect(viewModel.chats.first?.titleGenerationPending == false)
    #expect(viewModel.workspaceItems.first?.parentChatId == "root-chat")
    #expect(viewModel.workspaceItems.first?.titleGenerationPending == false)
    #expect(viewModel.selectedChat?.title == "Renamed chat")
    #expect(viewModel.selectedChat?.parentChatId == "root-chat")
    #expect(viewModel.selectedChat?.titleGenerationPending == false)
    #expect(viewModel.errorMessage == nil)
}

@MainActor
@Test func starringItemsUpdatesSidebarState() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_175)
    let chat = makeChatSummary(id: "chat-star", date: date)
    let search = makeSearchSummary(id: "search-star", date: date)
    var starredChat = chat
    starredChat.starred = true
    starredChat.starredAt = date.addingTimeInterval(5)
    var starredSearch = search
    starredSearch.starred = true
    starredSearch.starredAt = date.addingTimeInterval(10)

    let client = MockSybilClient(
        chatsResponse: [chat],
        searchesResponse: [search],
        updateChatStarResponses: ["chat-star": starredChat],
        updateSearchStarResponses: ["search-star": starredSearch]
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.chats = [chat]
    viewModel.searches = [search]
    viewModel.workspaceItems = [WorkspaceItem(chat: chat), WorkspaceItem(search: search)]

    await viewModel.setItemStarred(.chat("chat-star"), starred: true)
    await viewModel.setItemStarred(.search("search-star"), starred: true)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.updateChatStar == 1)
    #expect(snapshot.updateSearchStar == 1)
    #expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-star") })?.starred == true)
    #expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
}

@MainActor
@Test func unstarringForkTargetsRootWithoutReplacingSelectedChild() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_180)
    var root = makeChatSummary(id: "chat-root", date: date)
    root.starred = true
    root.starredAt = date.addingTimeInterval(5)

    var child = makeChatSummary(id: "chat-child", date: date.addingTimeInterval(1))
    child.parentChatId = root.id
    child.titleGenerationPending = true

    var childDetail = makeChatDetail(id: child.id, date: date, body: "forked transcript")
    childDetail.title = child.title
    childDetail.parentChatId = root.id
    childDetail.titleGenerationPending = true

    var unstarredRoot = root
    unstarredRoot.starred = false
    unstarredRoot.starredAt = nil

    let client = MockSybilClient(
        chatsResponse: [root, child],
        updateChatStarResponses: [root.id: unstarredRoot]
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.chats = [root, child]
    viewModel.workspaceItems = [WorkspaceItem(chat: root), WorkspaceItem(chat: child)]
    viewModel.selectedItem = .chat(child.id)
    viewModel.selectedChat = childDetail

    #expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == true)

    await viewModel.setItemStarred(.chat(child.id), starred: false)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.updateChatStar == 1)
    #expect(snapshot.lastUpdateChatStarID == root.id)
    #expect(snapshot.lastUpdateChatStarred == false)
    #expect(viewModel.chats.first(where: { $0.id == root.id })?.starred == false)
    #expect(viewModel.chats.first(where: { $0.id == child.id }) == child)
    #expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == false)
    #expect(viewModel.selectedItem == .chat(child.id))
    #expect(viewModel.selectedChat == childDetail)
    #expect(viewModel.errorMessage == nil)
}

@MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_200)
    let detail = makeSearchDetail(id: "search-2", date: date, answer: "fresh answer")
    let client = MockSybilClient(searchDetails: ["search-2": detail])
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .search("search-2")

    await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.listWorkspaceItems == 0)
    #expect(snapshot.listChats == 0)
    #expect(snapshot.listSearches == 0)
    #expect(snapshot.getSearch == 1)
    #expect(viewModel.selectedSearch?.answerText == "fresh answer")
}

@MainActor
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_210)
    let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
    let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
    let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
    await client.setGetChatDelay(50_000_000)
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-old")
    viewModel.selectedChat = staleDetail

    viewModel.select(.chat("chat-new"))

    #expect(viewModel.displayedMessages.isEmpty)
    #expect(viewModel.isLoadingSelection)

    try await Task.sleep(nanoseconds: 90_000_000)

    #expect(viewModel.displayedMessages.first?.content == "fresh transcript")
    #expect(!viewModel.isLoadingSelection)
    #expect(viewModel.chatBottomPinRequestID == 1)
}

@MainActor
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_220)
    let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
    let client = MockSybilClient(chatDetails: ["chat-fast": detail])
    await client.setGetChatDelay(20_000_000)
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false

    await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))

    #expect(viewModel.selectedItem == .chat("chat-fast"))
    #expect(viewModel.displayedMessages.first?.content == "loaded before push")
    #expect(!viewModel.isLoadingSelection)
}

@MainActor
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_230)
    let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
    let client = MockSybilClient(chatDetails: ["chat-slow": detail])
    await client.setGetChatDelay(100_000_000)
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false

    await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))

    #expect(viewModel.selectedItem == .chat("chat-slow"))
    #expect(viewModel.displayedMessages.isEmpty)
    #expect(viewModel.isLoadingSelection)

    try await Task.sleep(nanoseconds: 150_000_000)

    #expect(viewModel.displayedMessages.first?.content == "loaded after push")
    #expect(!viewModel.isLoadingSelection)
}

@MainActor
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_240)
    let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
    let client = MockSybilClient(chatDetails: ["chat-typing": detail])
    await client.setCompletionStreamNetworkError(
        "Network error -1005 while requesting POST: The network connection was lost.",
        delayNanoseconds: 50_000_000
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-typing")
    viewModel.selectedChat = detail
    viewModel.composer = "continue"

    let sendTask = Task {
        await viewModel.sendComposer()
    }
    try await Task.sleep(nanoseconds: 10_000_000)

    #expect(viewModel.isSendingVisibleChat)

    viewModel.startNewChat()

    #expect(viewModel.displayedMessages.isEmpty)
    #expect(!viewModel.isSendingVisibleChat)

    await sendTask.value
}

@MainActor
@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_245)
    let chat = makeChatSummary(id: "chat-pin", date: date)
    let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript")
    let client = MockSybilClient(
        chatsResponse: [chat],
        chatDetails: ["chat-pin": detail]
    )
    await client.setCompletionStreamEvents([
        .delta(CompletionStreamDelta(text: "partial ")),
        .delta(CompletionStreamDelta(text: "response")),
        .done(CompletionStreamDone(text: "partial response"))
    ])
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.chats = [chat]
    viewModel.workspaceItems = [WorkspaceItem(chat: chat)]
    viewModel.selectedItem = .chat("chat-pin")
    viewModel.selectedChat = detail
    viewModel.composer = "continue"

    let initialPinRequestID = viewModel.chatBottomPinRequestID
    await viewModel.sendComposer()

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.runCompletionStream == 1)
    #expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
}

@MainActor
@Test func firstPromptInPendingForkRequestsAndAppliesGeneratedTitle() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_247)
    var fork = makeChatSummary(id: "chat-fork", date: date)
    fork.title = "Fork of Original chat"
    fork.parentChatId = "root-chat"
    fork.titleGenerationPending = true

    var forkDetail = makeChatDetail(id: fork.id, date: date, body: "forked transcript")
    forkDetail.title = fork.title
    forkDetail.parentChatId = fork.parentChatId
    forkDetail.titleGenerationPending = true

    var titledFork = fork
    titledFork.title = "Investigating the follow-up"
    titledFork.titleGenerationPending = false
    titledFork.updatedAt = date.addingTimeInterval(1)

    var titledForkDetail = forkDetail
    titledForkDetail.title = titledFork.title
    titledForkDetail.titleGenerationPending = false
    titledForkDetail.updatedAt = titledFork.updatedAt

    let client = MockSybilClient(
        chatsResponse: [titledFork],
        chatDetails: [fork.id: titledForkDetail],
        suggestChatTitleResponses: [fork.id: titledFork]
    )
    await client.setCompletionStreamEvents(
        [.done(CompletionStreamDone(text: "Follow-up answer"))],
        delayNanoseconds: 100_000_000
    )

    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.chats = [fork]
    viewModel.workspaceItems = [WorkspaceItem(chat: fork)]
    viewModel.selectedItem = .chat(fork.id)
    viewModel.selectedChat = forkDetail
    viewModel.composer = "Investigate this follow-up"

    let sendTask = Task {
        await viewModel.sendComposer()
    }

    for _ in 0..<20 {
        let snapshot = await client.currentSnapshot()
        if snapshot.suggestChatTitle == 1,
           viewModel.selectedChat?.title == titledFork.title,
           viewModel.selectedChat?.titleGenerationPending == false {
            break
        }
        try await Task.sleep(nanoseconds: 5_000_000)
    }

    let titleSnapshot = await client.currentSnapshot()
    #expect(titleSnapshot.suggestChatTitle == 1)
    #expect(viewModel.chats.first?.title == titledFork.title)
    #expect(viewModel.workspaceItems.first?.title == titledFork.title)
    #expect(viewModel.selectedChat?.title == titledFork.title)
    #expect(viewModel.selectedChat?.titleGenerationPending == false)

    await sendTask.value
}

@MainActor
@Test func composerReturnSubmitsOnceAndConsumesRepeats() {
    var submissions = 0
    let initial = SybilReturnKeySubmission.handle(phase: .down, modifiers: []) { submissions += 1 }
    let repeated = SybilReturnKeySubmission.handle(phase: .repeat, modifiers: []) { submissions += 1 }
    #expect(initial == .handled)
    #expect(repeated == .handled)
    #expect(submissions == 1)
}

@MainActor
@Test func composerModifiedReturnPreservesNativeEditing() {
    var submissions = 0
    for modifiers: EventModifiers in [.shift, .command, .control, .option, [.shift, .command]] {
        let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: modifiers) { submissions += 1 }
        #expect(result == .ignored)
    }
    #expect(submissions == 0)
}

@MainActor
@Test func composerReturnStillSubmitsWithCapsLock() {
    var submissions = 0
    let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: .capsLock) { submissions += 1 }
    #expect(result == .handled)
    #expect(submissions == 1)
}

@Test func quickQuestionHotKeyTogglesOncePerPress() {
    var keyState = SybilQuickQuestionHotKeyState()
    let keyDownEvents = [true, true, true, false, true, false, false, true]
    let toggles = keyDownEvents.map { keyState.shouldToggle(isKeyDown: $0) }
    #expect(toggles == [true, false, false, false, true, false, false, true])
}

@MainActor
@Test func quickQuestionRejectsDuplicateSubmissionBeforeTaskStarts() async throws {
    let client = MockSybilClient()
    await client.setCompletionStreamEvents([.done(CompletionStreamDone(text: "One answer."))])
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.quickQuestionPrompt = "One question"

    let first = viewModel.sendQuickQuestion()
    let duplicate = viewModel.sendQuickQuestion()
    #expect(first != nil)
    #expect(duplicate == nil)
    #expect(viewModel.isQuickQuestionSending)
    await first?.value

    let calls = await client.currentSnapshot()
    #expect(calls.runCompletionStream == 1)
    #expect(viewModel.quickQuestionAnswerText == "One answer.")
    #expect(!viewModel.isQuickQuestionSending)
}

@MainActor
@Test func quickQuestionCancelledBeforeTaskStartsDoesNotSend() async throws {
    let client = MockSybilClient()
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.quickQuestionPrompt = "Do not send this"

    let task = viewModel.sendQuickQuestion()
    viewModel.cancelQuickQuestion()
    await task?.value

    let calls = await client.currentSnapshot()
    #expect(calls.runCompletionStream == 0)
    #expect(!viewModel.isQuickQuestionSending)
}

@MainActor
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
    let client = MockSybilClient()
    await client.setCompletionStreamEvents([
        .delta(CompletionStreamDelta(text: "Reset it from ")),
        .done(CompletionStreamDone(text: "Reset it from Settings."))
    ])
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.quickQuestionPrompt = "How do I reset my password?"

    let task = viewModel.sendQuickQuestion()
    await task?.value

    let snapshot = await client.currentSnapshot()
    let body = await client.currentCompletionStreamBody()
    #expect(snapshot.runCompletionStream == 1)
    #expect(body?.persist == false)
    #expect(body?.chatId == nil)
    #expect(body?.provider == .openai)
    #expect(body?.messages.first?.role == .user)
    #expect(body?.messages.first?.content == "How do I reset my password?")
    #expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
    #expect(!viewModel.isQuickQuestionSending)
}

@MainActor
@Test func quickQuestionConvertCreatesSeededChat() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_250)
    let chat = makeChatSummary(id: "quick-chat", date: date)
    let detail = ChatDetail(
        id: chat.id,
        title: chat.title,
        createdAt: chat.createdAt,
        updatedAt: chat.updatedAt,
        initiatedProvider: .openai,
        initiatedModel: "gpt-4.1-mini",
        lastUsedProvider: .openai,
        lastUsedModel: "gpt-4.1-mini",
        messages: [
            Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil),
            Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil)
        ]
    )
    let client = MockSybilClient(
        chatsResponse: [chat],
        chatDetails: [chat.id: detail],
        createChatResponse: chat
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?"
    viewModel.quickQuestionSubmittedProvider = .openai
    viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini"
    viewModel.quickQuestionMessages = [
        Message(
            id: "temp-assistant-quick",
            createdAt: date,
            role: .assistant,
            content: "Reset it from Settings.",
            name: nil
        )
    ]

    let didConvert = await viewModel.convertQuickQuestionToChat()

    let snapshot = await client.currentSnapshot()
    let createCall = await client.currentCreateChatCall()
    #expect(didConvert)
    #expect(snapshot.createChat == 1)
    #expect(createCall?.title == "How do I reset my password?")
    #expect(createCall?.provider == .openai)
    #expect(createCall?.model == "gpt-4.1-mini")
    #expect(createCall?.messages?.map(\.role) == [.user, .assistant])
    #expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."])
    #expect(viewModel.selectedItem == .chat("quick-chat"))
    #expect(viewModel.quickQuestionPrompt.isEmpty)
}

@MainActor
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
    let defaults = UserDefaults(suiteName: #function)!
    defaults.removePersistentDomain(forName: #function)
    let settings = SybilSettingsStore(defaults: defaults)
    settings.apiBaseURL = "http://127.0.0.1:8787"
    let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() }
    viewModel.modelCatalog = [
        .openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil),
        .anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil)
    ]

    viewModel.setQuickQuestionProvider(.anthropic)
    viewModel.setQuickQuestionModel("claude-3-haiku")

    #expect(viewModel.quickQuestionProvider == .anthropic)
    #expect(viewModel.quickQuestionModel == "claude-3-haiku")
    #expect(settings.preferredProvider == .openai)

    let reloadedSettings = SybilSettingsStore(defaults: defaults)
    #expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic)
    #expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku")
    #expect(reloadedSettings.preferredProvider == .openai)

    let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() }
    #expect(reloadedViewModel.quickQuestionProvider == .anthropic)
    #expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku")
    #expect(reloadedViewModel.provider == .openai)
}

@MainActor
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_260)
    let chat = makeChatSummary(id: "chat-active", date: date)
    let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript")
    let client = MockSybilClient(
        chatsResponse: [chat],
        chatDetails: ["chat-active": detail],
        activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
    )
    await client.setCompletionAttachEvents(
        chatID: "chat-active",
        events: [.delta(CompletionStreamDelta(text: "streaming"))],
        delayNanoseconds: 100_000_000
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }

    await viewModel.reconnect()
    try await Task.sleep(nanoseconds: 20_000_000)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.getActiveRuns >= 1)
    #expect(snapshot.attachCompletionStream == 1)
    #expect(viewModel.sidebarItems.first?.isRunning == true)
    #expect(viewModel.isSendingVisibleChat)
    #expect(viewModel.displayedMessages.last?.content == "streaming")
}

@MainActor
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_270)
    let activeChat = makeChatSummary(id: "chat-active", date: date)
    let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1))
    let client = MockSybilClient(
        chatsResponse: [idleChat, activeChat],
        chatDetails: [
            "chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"),
            "chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript")
        ],
        activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.selectedItem = .chat("chat-idle")
    viewModel.composer = "new message"

    await viewModel.reconnect()

    #expect(viewModel.selectedItem == .chat("chat-idle"))
    #expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true)
    #expect(!viewModel.isActiveSelectionSending)
    #expect(viewModel.canSendComposer)
}

@MainActor
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_300)
    let chat = makeChatSummary(id: "chat-3", date: date)
    let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript")
    let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript")
    let client = MockSybilClient(
        chatsResponse: [chat],
        chatDetails: ["chat-3": refreshedDetail]
    )
    await client.setCompletionStreamNetworkError(
        "Network error -1005 while requesting POST: The network connection was lost.",
        delayNanoseconds: 50_000_000
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-3")
    viewModel.selectedChat = initialDetail
    viewModel.composer = "continue"

    let sendTask = Task {
        await viewModel.sendComposer()
    }
    try await Task.sleep(nanoseconds: 10_000_000)
    viewModel.markAppInactiveForNetwork()
    await sendTask.value

    #expect(viewModel.errorMessage == nil)
    #expect(viewModel.composer.isEmpty)
    #expect(!viewModel.isSending)
    #expect(viewModel.selectedChat?.messages.first?.content == "stale transcript")

    await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.getChat == 1)
    #expect(viewModel.errorMessage == nil)
    #expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript")
}

@MainActor
@Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_400)
    let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer")
    let client = MockSybilClient(
        searchDetails: ["search-3": refreshedDetail]
    )
    await client.setSearchStreamNetworkError(
        "Network error -1005 while requesting POST: The network connection was lost.",
        delayNanoseconds: 50_000_000
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .search("search-3")
    viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer")
    viewModel.composer = "refresh me"

    let sendTask = Task {
        await viewModel.sendComposer()
    }
    try await Task.sleep(nanoseconds: 10_000_000)
    viewModel.markAppInactiveForNetwork()
    await sendTask.value

    #expect(viewModel.errorMessage == nil)
    #expect(viewModel.composer.isEmpty)
    #expect(!viewModel.isSending)

    await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.getSearch == 1)
    #expect(viewModel.errorMessage == nil)
    #expect(viewModel.selectedSearch?.answerText == "fresh answer")
}

@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
    let width: CGFloat = 390
    let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
    let latchDistance = NewChatSwipeMetrics.latchDistance(for: width)

    #expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel)
    #expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5)
    #expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10)
    #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width))
    #expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width))
    #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true))
    #expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true))
    #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0))
    #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
    #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
    #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
    #expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false))
    #expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false))
    #expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true))
    #expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel)
    #expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5)
    #expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width))
    #expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0))
    #expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140))
    #expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
    #expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
}

#if targetEnvironment(macCatalyst)
@MainActor
@Test func macNewChatCommandOpensWindowWhenNoWorkspacesRemain() {
    let commands = SybilMacWindowCommands()
    var chats = 0
    var windows = 0

    #expect(commands.canStartNewChat(hasFocusedActions: false))
    commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
    // A focused value can briefly outlive a closing scene. It must not route
    // Command+N back into a closed workspace.
    commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
    #expect(windows == 2)
    #expect(chats == 0)
}

@MainActor
@Test func macNewChatCommandUsesExistingWorkspace() {
    let commands = SybilMacWindowCommands()
    commands.windowConnected(sessionID: "first")
    var chats = 0
    var windows = 0

    #expect(commands.canStartNewChat(hasFocusedActions: true))
    commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
    #expect(chats == 1)
    #expect(windows == 0)
}

@MainActor
@Test func macNewChatCommandDoesNotMistakeUnavailableActionsForNoWindows() {
    let commands = SybilMacWindowCommands()
    commands.windowConnected(sessionID: "authenticating")
    var windows = 0

    #expect(!commands.canStartNewChat(hasFocusedActions: false))
    commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
    #expect(windows == 0)
}

@MainActor
@Test func macWindowCommandsTrackLastWindowClosureAndReconnection() {
    let commands = SybilMacWindowCommands()
    commands.windowConnected(sessionID: "first")
    commands.windowConnected(sessionID: "first")
    commands.windowConnected(sessionID: "second")
    commands.windowDisconnected(sessionID: "first")
    #expect(commands.hasOpenWindows)
    commands.windowDisconnected(sessionID: "first")
    #expect(commands.hasOpenWindows)
    commands.windowDisconnected(sessionID: "second")
    #expect(!commands.hasOpenWindows)
    #expect(commands.canStartNewChat(hasFocusedActions: false))
    commands.windowConnected(sessionID: "third")
    #expect(commands.hasOpenWindows)
    #expect(!commands.canStartNewChat(hasFocusedActions: false))
}

@MainActor
private final class MockQuickQuestionPanel: NSObject, SybilQuickQuestionPanelBridging {
    private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
    var isVisible = false
    var state = SybilQuickQuestionPanelState()
    var presentationCount = 0
    var activationCount = 0
    var stateUpdateCount = 0
    var events: [String] = []
    var onShow: (() -> Void)?

    required override init() { super.init() }
    func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
        self.delegate = delegate
        return 0
    }
    func updateState(_ data: Data) {
        state = try! JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data)
        stateUpdateCount += 1
        events.append("update")
    }
    func show() {
        onShow?()
        isVisible = true
        presentationCount += 1
        events.append("show")
    }
    func hide() {
        isVisible = false
        delegate?.quickQuestionPanelDismissed()
    }
    func activateForQuickQuestion() {
        activationCount += 1
        events.append("activate")
    }
    func activateMainWindow() {}
    func stop() { hide() }
}

@MainActor
private func waitForQuickQuestion(_ condition: @MainActor () -> Bool) async throws {
    for _ in 0..<100 {
        if condition() { return }
        try await Task.sleep(for: .milliseconds(2))
    }
    try #require(condition())
}

@MainActor
private final class MockQuickQuestionLifecycle {
    var isForeground = false
    let notifications = NotificationCenter()

    func notify(_ name: Notification.Name) {
        notifications.post(name: name, object: nil)
    }
}

@MainActor
@Test func macQuickQuestionWaitsForUIKitForegroundBeforeOpeningPanel() async throws {
    let lifecycle = MockQuickQuestionLifecycle()
    let panel = MockQuickQuestionPanel()
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
    viewModel.quickQuestionPrompt = "Original prompt"
    let controller = SybilMacQuickQuestionController(
        bridge: panel,
        isApplicationInForeground: { lifecycle.isForeground },
        notificationCenter: lifecycle.notifications
    )
    defer { controller.stop() }
    controller.attach(viewModel)
    panel.onShow = { #expect(lifecycle.isForeground) }

    controller.toggle()
    // Never create/order a window inline in the hotkey's event callback.
    #expect(panel.presentationCount == 0)
    #expect(panel.activationCount == 0)
    try await waitForQuickQuestion { panel.activationCount == 1 }
    #expect(panel.stateUpdateCount == 0)
    #expect(!panel.isVisible)

    // "Will enter" is not proof of readiness: UIKit can still be backgrounded.
    lifecycle.notify(UIApplication.willEnterForegroundNotification)
    try await Task.sleep(for: .milliseconds(10))
    #expect(panel.activationCount == 1)
    #expect(panel.presentationCount == 0)
    #expect(panel.stateUpdateCount == 0)

    viewModel.quickQuestionPrompt = "The latest prompt while waiting"
    lifecycle.isForeground = true
    lifecycle.notify(UIApplication.didBecomeActiveNotification)
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.events == ["activate", "update", "show"])
    #expect(panel.state.prompt == "The latest prompt while waiting")
}

@MainActor
@Test func macQuickQuestionSecondToggleCancelsPendingActivation() async throws {
    let lifecycle = MockQuickQuestionLifecycle()
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(
        bridge: panel,
        isApplicationInForeground: { lifecycle.isForeground },
        notificationCenter: lifecycle.notifications
    )
    defer { controller.stop() }
    controller.toggle()
    try await waitForQuickQuestion { panel.activationCount == 1 }
    controller.toggle()

    lifecycle.isForeground = true
    lifecycle.notify(UIApplication.didBecomeActiveNotification)
    try await Task.sleep(for: .milliseconds(10))
    #expect(!panel.isVisible)
    #expect(panel.presentationCount == 0)
    #expect(panel.stateUpdateCount == 0)

    controller.toggle()
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.presentationCount == 1)
    #expect(panel.activationCount == 1)
}

@MainActor
@Test func macQuickQuestionRapidTogglesKeepOnlyLatestPresentation() async throws {
    let lifecycle = MockQuickQuestionLifecycle()
    lifecycle.isForeground = true
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(
        bridge: panel,
        isApplicationInForeground: { lifecycle.isForeground },
        notificationCenter: lifecycle.notifications
    )
    defer { controller.stop() }
    controller.toggle()
    controller.toggle()
    controller.toggle()
    #expect(panel.presentationCount == 0)
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.presentationCount == 1)
    #expect(panel.activationCount == 0)

    // Lifecycle notifications must not present/select the prompt a second time.
    lifecycle.notify(UIApplication.willEnterForegroundNotification)
    lifecycle.notify(UIApplication.didBecomeActiveNotification)
    try await Task.sleep(for: .milliseconds(10))
    #expect(panel.presentationCount == 1)
}

@MainActor
@Test func macQuickQuestionStopCancelsPendingPresentation() async throws {
    let lifecycle = MockQuickQuestionLifecycle()
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(
        bridge: panel,
        isApplicationInForeground: { lifecycle.isForeground },
        notificationCenter: lifecycle.notifications
    )
    controller.show()
    try await waitForQuickQuestion { panel.activationCount == 1 }
    controller.stop()
    lifecycle.isForeground = true
    lifecycle.notify(UIApplication.didBecomeActiveNotification)
    try await Task.sleep(for: .milliseconds(10))
    #expect(panel.presentationCount == 0)
    #expect(panel.stateUpdateCount == 0)
}

@MainActor
@Test func macQuickQuestionRechecksForegroundAfterLeavingHotKeyCallback() async throws {
    let lifecycle = MockQuickQuestionLifecycle()
    lifecycle.isForeground = true
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(
        bridge: panel,
        isApplicationInForeground: { lifecycle.isForeground },
        notificationCenter: lifecycle.notifications
    )
    defer { controller.stop() }
    controller.show()
    lifecycle.isForeground = false
    try await waitForQuickQuestion { panel.activationCount == 1 }
    #expect(!panel.isVisible)
    #expect(panel.stateUpdateCount == 0)
    lifecycle.isForeground = true
    lifecycle.notify(UIApplication.didBecomeActiveNotification)
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.presentationCount == 1)
}

@MainActor
@Test func macQuickQuestionTogglePreservesDraftAndUsesLatestAnswer() async throws {
    let client = MockSybilClient()
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.quickQuestionPrompt = "Keep this draft"
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(bridge: panel)
    controller.attach(viewModel)

    controller.toggle()
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.isVisible)
    #expect(panel.state.prompt == "Keep this draft")
    #expect(panel.state.canSend)
    controller.toggle()
    #expect(!panel.isVisible)

    viewModel.quickQuestionMessages = [
        Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "An answer arrived while hidden.", name: nil)
    ]
    controller.toggle()
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.isVisible)
    #expect(panel.presentationCount == 2)
    #expect(panel.state.prompt == "Keep this draft")
    #expect(panel.state.answer == "An answer arrived while hidden.")
    let calls = await client.currentSnapshot()
    #expect(calls.runCompletionStream == 0)
    controller.stop()
}

@MainActor
@Test func macQuickQuestionObservesStreamingAndKeepsItsOwner() async throws {
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.quickQuestionPrompt = "Original window"
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(bridge: panel)
    controller.attach(viewModel)
    controller.show()

    let otherWindow = SybilViewModel(settings: testSettings(named: #function + "-other")) { _ in MockSybilClient() }
    otherWindow.quickQuestionPrompt = "Other window"
    controller.attach(otherWindow)
    viewModel.quickQuestionMessages = [
        Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "Streaming update", name: nil)
    ]
    for _ in 0..<30 {
        if panel.state.answer == "Streaming update" { break }
        try await Task.sleep(for: .milliseconds(5))
    }
    #expect(panel.state.answer == "Streaming update")
    #expect(panel.state.prompt == "Original window")
    controller.stop()
}

@MainActor
@Test func macQuickQuestionRetainsStateAfterLastWorkspaceCloses() async throws {
    let client = MockSybilClient()
    var windowViewModel: SybilViewModel? = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    windowViewModel?.isAuthenticated = true
    windowViewModel?.isCheckingSession = false
    windowViewModel?.quickQuestionPrompt = "A question from the closed window"
    windowViewModel?.quickQuestionMessages = [
        Message(id: "temp-assistant-quick-windowless", createdAt: Date(), role: .assistant, content: "Keep this answer too.", name: nil)
    ]
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(bridge: panel)
    controller.attach(try #require(windowViewModel))

    // Releasing the workspace must not release the global shortcut's model.
    windowViewModel = nil
    controller.toggle()
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.isVisible)
    #expect(panel.state.prompt == "A question from the closed window")
    #expect(panel.state.answer == "Keep this answer too.")
    controller.toggle()
    #expect(!panel.isVisible)
    controller.toggle()
    try await waitForQuickQuestion { panel.isVisible }
    #expect(panel.presentationCount == 2)
    #expect(panel.state.canSend)
    let calls = await client.currentSnapshot()
    #expect(calls.runCompletionStream == 0)
    controller.stop()
}

@MainActor
@Test func macQuickQuestionCannotSubmitBeforeAuthentication() async throws {
    let client = MockSybilClient()
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = false
    viewModel.isCheckingSession = false
    let panel = MockQuickQuestionPanel()
    let controller = SybilMacQuickQuestionController(bridge: panel)
    controller.attach(viewModel)
    controller.show()
    try await waitForQuickQuestion { panel.isVisible }
    controller.quickQuestionPromptChanged("A signed-out question")
    controller.quickQuestionSubmitRequested()

    #expect(!panel.state.canSend)
    #expect(panel.state.prompt == "A signed-out question")
    #expect(!viewModel.isQuickQuestionSending)
    let calls = await client.currentSnapshot()
    #expect(calls.runCompletionStream == 0)
    controller.stop()
}
#endif
