import Foundation
import Observation

enum DraftKind {
    case chat
    case search
}

enum SidebarSelection: Hashable {
    case chat(String)
    case search(String)
    case settings

    var id: String {
        switch self {
        case let .chat(chatID):
            return "chat:\(chatID)"
        case let .search(searchID):
            return "search:\(searchID)"
        case .settings:
            return "settings"
        }
    }
}

struct SidebarItem: Identifiable, Hashable {
    enum Kind: Hashable {
        case chat
        case search
    }

    var id: String { selection.id }
    var selection: SidebarSelection
    var kind: Kind
    var title: String
    var updatedAt: Date
    var starred: Bool
    var starredAt: Date?
    var initiatedLabel: String?
    var isRunning: Bool
}

private struct PendingChatState {
    var chatID: String?
    var messages: [Message]
}

private enum ActiveSendContext: Hashable {
    case draftChat(UUID)
    case chat(String)
    case draftSearch(UUID)
    case search(String)

    var isSearch: Bool {
        switch self {
        case .draftSearch, .search:
            return true
        case .draftChat, .chat:
            return false
        }
    }
}

private actor CompletionStreamStatus {
    private var streamError: String?

    func setError(_ value: String) {
        streamError = value
    }

    func error() -> String? {
        streamError
    }
}

private actor SearchStreamStatus {
    private var streamError: String?

    func setError(_ value: String) {
        streamError = value
    }

    func error() -> String? {
        streamError
    }
}

@MainActor
@Observable
final class SybilViewModel {
    let settings: SybilSettingsStore

    var isCheckingSession = true
    var isAuthenticated = false
    var authMode: String?
    var authError: String?

    var chats: [ChatSummary] = []
    var searches: [SearchSummary] = []
    var workspaceItems: [WorkspaceItem] = []

    var selectedItem: SidebarSelection?
    var selectedChat: ChatDetail?
    var selectedSearch: SearchDetail?
    var draftKind: DraftKind?

    var isLoadingCollections = false
    var isLoadingSelection = false
    var isCreatingSearchChat = false
    var chatBottomPinRequestID = 0
    var errorMessage: String?

    var composer = ""
    var composerAttachments: [ChatAttachment] = []
    var provider: Provider
    var modelCatalog: [Provider: ProviderModelInfo] = [:]
    var model: String
    var quickQuestionPrompt = ""
    var quickQuestionMessages: [Message] = []
    var quickQuestionError: String?
    var quickQuestionProvider: Provider
    var quickQuestionModel: String
    var quickQuestionSubmittedPrompt: String?
    var quickQuestionSubmittedProvider: Provider?
    var quickQuestionSubmittedModel: String?
    var isQuickQuestionSending = false
    var isConvertingQuickQuestion = false

    @ObservationIgnored
    private var hasBootstrapped = false
    private var pendingDraftChatState: PendingChatState?
    private var pendingChatStates: [String: PendingChatState] = [:]
    private var activeSearchDetails: [String: SearchDetail] = [:]
    private var activeDraftSendContexts: Set<ActiveSendContext> = []
    private var localActiveChatIDs: Set<String> = []
    private var localActiveSearchIDs: Set<String> = []
    private var serverActiveChatIDs: Set<String> = []
    private var serverActiveSearchIDs: Set<String> = []
    private var draftIdentity = UUID()
    @ObservationIgnored
    private var selectionTask: Task<Void, Never>?
    @ObservationIgnored
    private var activeRunPollingTask: Task<Void, Never>?
    @ObservationIgnored
    private var activeChatAttachTasks: [String: Task<Void, Never>] = [:]
    @ObservationIgnored
    private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
    @ObservationIgnored
    private var quickQuestionTask: Task<Void, Never>?
    @ObservationIgnored
    private var quickQuestionRunID: UUID?
    @ObservationIgnored
    private var isAppActive = true
    @ObservationIgnored
    private var appLifecycleGeneration = 0
    @ObservationIgnored
    private let clientFactory: (APIConfiguration) -> any SybilAPIClienting

    private let fallbackModels: [Provider: [String]] = [
        .openai: ["gpt-4.1-mini"],
        .anthropic: ["claude-3-5-sonnet-latest"],
        .xai: ["grok-3-mini"],
        .gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
        .hermesAgent: ["hermes-agent"]
    ]

    init(
        settings: SybilSettingsStore = SybilSettingsStore(),
        clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
            SybilAPIClient(configuration: configuration)
        }
    ) {
        self.settings = settings
        self.clientFactory = clientFactory
        let initialProvider = settings.preferredProvider
        let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
        self.provider = initialProvider
        self.model = initialModel
        let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider
        let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel
        self.quickQuestionProvider = initialQuickQuestionProvider
        self.quickQuestionModel = initialQuickQuestionModel
    }

    var providerModelOptions: [String] {
        modelOptions(for: provider)
    }

    var providerOptions: [Provider] {
        Provider.allCases.filter { candidate in
            candidate != .hermesAgent || modelCatalog[candidate] != nil
        }
    }

    var quickQuestionProviderModelOptions: [String] {
        modelOptions(for: quickQuestionProvider)
    }

    var canSendQuickQuestion: Bool {
        !isQuickQuestionSending &&
            !isConvertingQuickQuestion &&
            !quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
            !quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }

    var quickQuestionAnswerText: String {
        for message in quickQuestionMessages.reversed() where message.role == .assistant {
            let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
            if !content.isEmpty {
                return content
            }
        }
        return ""
    }

    var canConvertQuickQuestion: Bool {
        !isQuickQuestionSending &&
            !isConvertingQuickQuestion &&
            !(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
            !quickQuestionAnswerText.isEmpty &&
            quickQuestionSubmittedProvider != nil &&
            !(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
    }

    func modelOptions(for candidate: Provider) -> [String] {
        let serverModels = modelCatalog[candidate]?.models ?? []
        if !serverModels.isEmpty {
            return serverModels
        }
        return fallbackModels[candidate] ?? []
    }

    var selectedTitle: String {
        if case .settings = selectedItem {
            return "Settings"
        }
        if draftKind == .chat {
            return "New chat"
        }
        if draftKind == .search {
            return "New search"
        }

        guard let selectedItem else {
            return "Sybil"
        }

        switch selectedItem {
        case .chat:
            if let selectedChat = currentSelectedChat {
                return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
            }
            if let summary = selectedChatSummary {
                return chatTitle(title: summary.title, messages: nil)
            }
            return "Chat"

        case .search:
            if let selectedSearch = currentSelectedSearch {
                return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
            }
            if let summary = selectedSearchSummary {
                return searchTitle(title: summary.title, query: summary.query)
            }
            return "Search"

        case .settings:
            return "Settings"
        }
    }

    var subtitle: String {
        if case .settings = selectedItem {
            return "Configure API connectivity"
        }

        let modeLabel: String
        if authMode == "open" {
            modeLabel = "open mode"
        } else if authMode == "token" {
            modeLabel = "token mode"
        } else {
            modeLabel = "offline"
        }

        if isSearchMode {
            return "Sybil iOS (\(modeLabel)) • Exa Search"
        }
        return "Sybil iOS (\(modeLabel))"
    }

    var isSearchMode: Bool {
        if draftKind == .search {
            return true
        }
        if draftKind == .chat {
            return false
        }
        if case .search = selectedItem {
            return true
        }
        return false
    }

    var showsComposer: Bool {
        if case .settings = selectedItem {
            return false
        }
        return draftKind != nil || selectedItem != nil
    }

    private var activeChatIDs: Set<String> {
        localActiveChatIDs.union(serverActiveChatIDs)
    }

    private var activeSearchIDs: Set<String> {
        localActiveSearchIDs.union(serverActiveSearchIDs)
    }

    private func isChatRowRunning(_ chatID: String) -> Bool {
        pendingChatStates[chatID] != nil || activeChatIDs.contains(chatID)
    }

    private func isSearchRowRunning(_ searchID: String) -> Bool {
        activeSearchDetails[searchID] != nil || activeSearchIDs.contains(searchID)
    }

    var isSending: Bool {
        !activeDraftSendContexts.isEmpty || !activeChatIDs.isEmpty || !activeSearchIDs.isEmpty
    }

    var isActiveSelectionSending: Bool {
        isSendContextActive(currentSendContext)
    }

    var canSendComposer: Bool {
        if isActiveSelectionSending {
            return false
        }

        let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
        if isSearchMode {
            return !content.isEmpty
        }

        return !content.isEmpty || !composerAttachments.isEmpty
    }

    var displayedMessages: [Message] {
        let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
        if case let .chat(chatID) = selectedItem,
           let pending = pendingChatStates[chatID] {
            return displayableMessages(pending.messages)
        }

        if draftKind == .chat, let pending = pendingDraftChatState {
            return displayableMessages(pending.messages)
        }

        return canonical
    }

    var displayedSearch: SearchDetail? {
        if case let .search(searchID) = selectedItem,
           let activeSearch = activeSearchDetails[searchID] {
            return activeSearch
        }
        return currentSelectedSearch
    }

    var isSendingVisibleChat: Bool {
        if draftKind == .chat {
            return activeDraftSendContexts.contains(.draftChat(draftIdentity))
        }

        if case let .chat(chatID) = selectedItem {
            return isChatRowRunning(chatID)
        }

        return false
    }

    var isRunningVisibleSearch: Bool {
        if draftKind == .search {
            return activeDraftSendContexts.contains(.draftSearch(draftIdentity))
        }

        if case let .search(searchID) = selectedItem {
            return isSearchRowRunning(searchID)
        }

        return false
    }

    var sidebarItems: [SidebarItem] {
        workspaceItems.map { item in
            switch item.type {
            case .chat:
                let initiatedLabel: String?
                if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
                    if let provider = item.initiatedProvider {
                        initiatedLabel = "\(provider.displayName) • \(model)"
                    } else {
                        initiatedLabel = model
                    }
                } else {
                    initiatedLabel = nil
                }
                let starOwner = item.parentChatId.flatMap { parentChatID in
                    workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
                } ?? item

                return SidebarItem(
                    selection: .chat(item.id),
                    kind: .chat,
                    title: chatTitle(title: item.title, messages: nil),
                    updatedAt: item.updatedAt,
                    starred: starOwner.starred,
                    starredAt: starOwner.starredAt,
                    initiatedLabel: initiatedLabel,
                    isRunning: isChatRowRunning(item.id)
                )

            case .search:
                return SidebarItem(
                    selection: .search(item.id),
                    kind: .search,
                    title: searchTitle(title: item.title, query: item.query),
                    updatedAt: item.updatedAt,
                    starred: item.starred,
                    starredAt: item.starredAt,
                    initiatedLabel: "exa",
                    isRunning: isSearchRowRunning(item.id)
                )
            }
        }
    }

    var selectedChatSummary: ChatSummary? {
        guard case let .chat(chatID) = selectedItem else { return nil }
        return chats.first(where: { $0.id == chatID })
    }

    var selectedSearchSummary: SearchSummary? {
        guard case let .search(searchID) = selectedItem else { return nil }
        return searches.first(where: { $0.id == searchID })
    }

    var hasRefreshableSelection: Bool {
        guard draftKind == nil, let selectedItem else {
            return false
        }

        switch selectedItem {
        case .chat, .search:
            return true
        case .settings:
            return false
        }
    }

    func bootstrap() async {
        guard !hasBootstrapped else {
            return
        }

        SybilLog.info(SybilLog.app, "Bootstrapping Sybil iOS session")
        hasBootstrapped = true
        await reconnect()
    }

    func reconnect() async {
        isCheckingSession = true
        authError = nil
        errorMessage = nil
        resetSelectionLoading()
        stopActiveRunPolling()
        cancelActiveStreamAttachTasks()
        pendingDraftChatState = nil
        pendingChatStates = [:]
        activeSearchDetails = [:]
        activeDraftSendContexts = []
        localActiveChatIDs = []
        localActiveSearchIDs = []
        serverActiveChatIDs = []
        serverActiveSearchIDs = []
        resetQuickQuestion()
        draftIdentity = UUID()
        composerAttachments = []
        settings.persist()

        SybilLog.info(
            SybilLog.app,
            "Reconnecting with API base URL \(settings.normalizedAPIBaseURL?.absoluteString ?? "<invalid>")"
        )

        do {
            let client = try client()
            let session = try await client.verifySession()
            isAuthenticated = session.authenticated
            authMode = session.mode
            authError = nil

            SybilLog.info(
                SybilLog.app,
                "Session verified (authenticated=\(session.authenticated), mode=\(session.mode))"
            )

            await loadInitialData(using: client)
            startActiveRunPolling()
        } catch {
            stopActiveRunPolling()
            isAuthenticated = false
            authMode = nil
            chats = []
            searches = []
            workspaceItems = []
            selectedItem = .settings
            selectedChat = nil
            selectedSearch = nil
            draftKind = nil
            authError = normalizeAuthError(error)
            SybilLog.error(SybilLog.app, "Reconnect failed", error: error)
        }

        isCheckingSession = false
    }

    func setProvider(_ nextProvider: Provider) {
        provider = nextProvider

        let preferred = settings.preferredModelByProvider[nextProvider]
        let options = providerModelOptions
        if let preferred, options.contains(preferred) {
            model = preferred
        } else if let first = options.first {
            model = first
        }

        settings.preferredProvider = nextProvider
        settings.persist()
        SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(model)")
    }

    func setModel(_ nextModel: String) {
        model = nextModel
        settings.preferredModelByProvider[provider] = nextModel
        settings.persist()
        SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)")
    }

    func setProvider(_ nextProvider: Provider, model nextModel: String) {
        provider = nextProvider
        model = nextModel
        settings.preferredProvider = nextProvider
        settings.preferredModelByProvider[nextProvider] = nextModel
        settings.persist()
        SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
    }

    func setQuickQuestionProvider(_ nextProvider: Provider) {
        quickQuestionProvider = nextProvider

        let options = modelOptions(for: nextProvider)
        if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) {
            quickQuestionModel = preferred
        } else if let first = options.first {
            quickQuestionModel = first
        } else {
            quickQuestionModel = ""
        }

        persistQuickQuestionModelSelection()
    }

    func setQuickQuestionModel(_ nextModel: String) {
        quickQuestionModel = nextModel
        persistQuickQuestionModelSelection()
    }

    private func persistQuickQuestionModelSelection() {
        settings.quickQuestionPreferredProvider = quickQuestionProvider
        let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmedModel.isEmpty {
            settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel
        }
        settings.persist()
    }

    func updateQuickQuestionPrompt(_ nextPrompt: String) {
        guard nextPrompt != quickQuestionPrompt else {
            return
        }

        if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty {
            cancelQuickQuestion()
            quickQuestionSubmittedPrompt = nil
            quickQuestionSubmittedProvider = nil
            quickQuestionSubmittedModel = nil
            quickQuestionMessages = []
            quickQuestionError = nil
        }

        quickQuestionPrompt = nextPrompt
    }

    func resetQuickQuestion() {
        cancelQuickQuestion()
        quickQuestionPrompt = ""
        quickQuestionMessages = []
        quickQuestionError = nil
        quickQuestionSubmittedPrompt = nil
        quickQuestionSubmittedProvider = nil
        quickQuestionSubmittedModel = nil
        isConvertingQuickQuestion = false
    }

    func cancelQuickQuestion() {
        quickQuestionTask?.cancel()
        quickQuestionTask = nil
        quickQuestionRunID = nil
        isQuickQuestionSending = false
    }

    @discardableResult
    func sendQuickQuestion() -> Task<Void, Never>? {
        let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
            return nil
        }

        let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !selectedModel.isEmpty else {
            quickQuestionError = "No model available for selected provider."
            return nil
        }

        cancelQuickQuestion()
        // Lock submission before the task starts, including repeated Return
        // events delivered in the same main-actor turn.
        isQuickQuestionSending = true
        let selectedProvider = quickQuestionProvider
        let task = Task { [weak self] in
            guard let self, !Task.isCancelled else {
                return
            }
            await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
        }
        quickQuestionTask = task
        return task
    }

    @discardableResult
    func convertQuickQuestionToChat() async -> Bool {
        let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        let answer = quickQuestionAnswerText
        guard !question.isEmpty,
              !answer.isEmpty,
              let submittedProvider = quickQuestionSubmittedProvider,
              let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
              !submittedModel.isEmpty,
              !isQuickQuestionSending,
              !isConvertingQuickQuestion
        else {
            return false
        }

        isConvertingQuickQuestion = true
        quickQuestionError = nil
        defer {
            isConvertingQuickQuestion = false
        }

        do {
            let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
            let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
            let chat = try await client().createChat(
                title: title.isEmpty ? "Quick question" : title,
                provider: submittedProvider,
                model: submittedModel,
                messages: [
                    CompletionRequestMessage(role: .user, content: question),
                    CompletionRequestMessage(role: .assistant, content: answer)
                ]
            )

            setProvider(submittedProvider, model: submittedModel)
            chats.removeAll(where: { $0.id == chat.id })
            chats.insert(chat, at: 0)
            upsertWorkspaceChat(chat)
            draftKind = nil
            selectedItem = .chat(chat.id)
            selectedChat = ChatDetail(
                id: chat.id,
                title: chat.title,
                parentChatId: chat.parentChatId,
                titleGenerationPending: chat.titleGenerationPending,
                createdAt: chat.createdAt,
                updatedAt: chat.updatedAt,
                starred: chat.starred,
                starredAt: chat.starredAt,
                initiatedProvider: chat.initiatedProvider,
                initiatedModel: chat.initiatedModel,
                lastUsedProvider: chat.lastUsedProvider,
                lastUsedModel: chat.lastUsedModel,
                messages: []
            )
            selectedSearch = nil
            composer = ""
            composerAttachments = []

            await refreshCollections(preferredSelection: .chat(chat.id))
            resetQuickQuestion()
            return true
        } catch {
            quickQuestionError = normalizeAPIError(error)
            SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error)
            return false
        }
    }

    func startNewChat() {
        SybilLog.debug(SybilLog.ui, "Starting draft chat")
        resetSelectionLoading()
        draftIdentity = UUID()
        draftKind = .chat
        selectedItem = nil
        selectedChat = nil
        selectedSearch = nil
        pendingDraftChatState = nil
        errorMessage = nil
        composer = ""
        composerAttachments = []
    }

    func startNewSearch() {
        SybilLog.debug(SybilLog.ui, "Starting draft search")
        resetSelectionLoading()
        draftIdentity = UUID()
        draftKind = .search
        selectedItem = nil
        selectedChat = nil
        selectedSearch = nil
        pendingDraftChatState = nil
        errorMessage = nil
        composer = ""
        composerAttachments = []
    }

    func openSettings() {
        SybilLog.debug(SybilLog.ui, "Opening settings")
        resetSelectionLoading()
        draftKind = nil
        selectedItem = .settings
        selectedChat = nil
        selectedSearch = nil
        pendingDraftChatState = nil
        errorMessage = nil
        composerAttachments = []
    }

    func select(_ selection: SidebarSelection) {
        _ = beginSelecting(selection)
    }

    func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async {
        guard beginSelecting(selection) != nil else {
            return
        }

        await waitForSelectionLoad(timeout: preloadTimeout)
    }

    @discardableResult
    private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
        SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")

        if draftKind == nil, selectedItem == selection {
            errorMessage = nil
            if case .search = selection {
                composerAttachments = []
            }

            if needsSelectionLoad(selection) {
                return startSelectionRefreshTask()
            }

            return selectionTask
        }

        resetSelectionLoading()
        draftKind = nil
        selectedItem = selection
        errorMessage = nil

        switch selection {
        case let .chat(chatID):
            if selectedChat?.id != chatID {
                selectedChat = nil
            }
            selectedSearch = nil

        case let .search(searchID):
            if selectedSearch?.id != searchID {
                selectedSearch = nil
            }
            selectedChat = nil
            composerAttachments = []

        case .settings:
            selectedChat = nil
            selectedSearch = nil
            return nil
        }

        return startSelectionRefreshTask()
    }

    func selectPreviousSidebarItem() {
        selectAdjacentSidebarItem(offset: -1)
    }

    func selectNextSidebarItem() {
        selectAdjacentSidebarItem(offset: 1)
    }

    private func selectAdjacentSidebarItem(offset: Int) {
        let items = sidebarItems
        guard !items.isEmpty else {
            return
        }

        let currentIndex = selectedItem.flatMap { selection in
            items.firstIndex { $0.selection == selection }
        }
        let startingIndex = currentIndex ?? (offset < 0 ? items.count : -1)
        let nextIndex = (startingIndex + offset + items.count) % items.count
        let nextSelection = items[nextIndex].selection

        guard draftKind != nil || selectedItem != nextSelection else {
            return
        }

        select(nextSelection)
    }

    func deleteItem(_ selection: SidebarSelection) async {
        guard isAuthenticated else {
            return
        }
        guard case .settings = selection else {
            SybilLog.info(SybilLog.ui, "Deleting item \(selection.id)")
            do {
                let client = try client()
                switch selection {
                case let .chat(chatID):
                    try await client.deleteChat(chatID: chatID)
                case let .search(searchID):
                    try await client.deleteSearch(searchID: searchID)
                case .settings:
                    break
                }
                await refreshCollections(preferredSelection: nil)
            } catch {
                errorMessage = normalizeAPIError(error)
                SybilLog.error(SybilLog.ui, "Delete failed", error: error)
            }
            return
        }
    }

    func renameChat(chatID: String, title: String) async {
        guard isAuthenticated else {
            return
        }

        let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedTitle.isEmpty else {
            errorMessage = "Enter a chat title."
            return
        }

        SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
        errorMessage = nil

        do {
            let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
            applyChatSummary(updated, moveToFront: true)
        } catch {
            errorMessage = normalizeAPIError(error)
            SybilLog.error(SybilLog.ui, "Rename failed", error: error)
        }
    }

    func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
        guard isAuthenticated else {
            return
        }

        guard case .settings = selection else {
            errorMessage = nil

            do {
                let client = try client()
                switch selection {
                case let .chat(chatID):
                    let rootChatID = chatFamilyRootID(for: chatID)
                    let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
                    applyChatSummary(updated, moveToFront: false)
                case let .search(searchID):
                    let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
                    applySearchSummary(updated, moveToFront: false)
                case .settings:
                    break
                }
            } catch {
                errorMessage = normalizeAPIError(error)
                SybilLog.error(SybilLog.ui, "Star update failed", error: error)
            }
            return
        }
    }

    func refreshAfterSettingsChange() async {
        SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
        settings.persist()
        await reconnect()
    }

    func markAppInactiveForNetwork() {
        guard isAppActive else {
            return
        }

        isAppActive = false
        appLifecycleGeneration += 1
        SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
    }

    func markAppActiveForNetwork() {
        isAppActive = true
    }

    func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
        markAppActiveForNetwork()

        guard isAuthenticated, !isCheckingSession else {
            return
        }

        guard shouldRefreshCollections || shouldRefreshSelection else {
            return
        }

        try? await Task.sleep(for: .milliseconds(150))
        await refreshVisibleContent(
            refreshCollections: shouldRefreshCollections,
            refreshSelection: shouldRefreshSelection
        )
    }

    func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
        guard isAuthenticated, !isCheckingSession else {
            return
        }

        guard shouldRefreshCollections || shouldRefreshSelection else {
            return
        }

        SybilLog.info(
            SybilLog.ui,
            "Visible content refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
        )

        if shouldRefreshCollections {
            await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection)
            return
        }

        if shouldRefreshSelection {
            await refreshActiveRunsFromServer()
            await refreshSelectionIfNeeded()
        }
    }

    func refreshSidebarCollectionsFromPullToRefresh() async {
        guard isAuthenticated, !isCheckingSession else {
            return
        }

        SybilLog.info(
            SybilLog.ui,
            "Sidebar pull-to-refresh requested"
        )

        let preferredSelection = selectedItem
        let refreshTask = Task { @MainActor in
            await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
        }
        await refreshTask.value
    }

    func sendComposer() async {
        let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
        let attachments = composerAttachments
        let sendContext = currentSendContext

        guard !isSendContextActive(sendContext) else {
            return
        }

        if sendContext.isSearch {
            guard !content.isEmpty else { return }
        } else if content.isEmpty && attachments.isEmpty {
            return
        }

        composer = ""
        composerAttachments = []
        errorMessage = nil
        markSendContextActive(sendContext)
        defer {
            markSendContextInactive(sendContext)
        }

        do {
            if sendContext.isSearch {
                SybilLog.info(SybilLog.ui, "Sending search query")
                try await sendSearch(query: content, sendContext: sendContext)
            } else {
                SybilLog.info(SybilLog.ui, "Sending chat prompt")
                try await sendChat(content: content, attachments: attachments, sendContext: sendContext)
            }
        } catch {
            let shouldSurfaceError = isSendContextVisible(sendContext)
            if shouldSurfaceError {
                errorMessage = normalizeAPIError(error)
            }
            SybilLog.error(SybilLog.ui, "Send failed", error: error)

            if shouldSurfaceError, case let .chat(chatID) = selectedItem {
                do {
                    let chat = try await client().getChat(chatID: chatID)
                    if selectedItem == .chat(chatID), draftKind == nil {
                        selectedChat = chat
                    }
                } catch {
                    SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
                }
            }

            if shouldSurfaceError, case let .search(searchID) = selectedItem {
                do {
                    let search = try await client().getSearch(searchID: searchID)
                    if selectedItem == .search(searchID), draftKind == nil {
                        selectedSearch = search
                    }
                } catch {
                    SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
                }
            }

            if !sendContext.isSearch, shouldSurfaceError {
                composer = content
                composerAttachments = attachments
            }
            clearPendingChatState(for: sendContext)
        }
    }

    func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
        guard !attachments.isEmpty else {
            return
        }

        guard !isSearchMode else {
            errorMessage = "Attachments are only available in chat mode."
            return
        }

        if composerAttachments.count + attachments.count > SybilChatAttachmentSupport.maxAttachmentsPerMessage {
            throw ChatAttachmentError.tooManyAttachments(SybilChatAttachmentSupport.maxAttachmentsPerMessage)
        }

        composerAttachments += attachments
        errorMessage = nil
    }

    func removeComposerAttachment(id: String) {
        composerAttachments.removeAll { $0.id == id }
    }

    func startChatFromSelectedSearch() async {
        guard let search = currentSelectedSearch, !isCreatingSearchChat, !isActiveSelectionSending else {
            return
        }

        let sourceSelection = SidebarSelection.search(search.id)
        isCreatingSearchChat = true
        errorMessage = nil

        do {
            let client = try client()
            let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)

            guard selectedItem == sourceSelection, draftKind == nil else {
                chats.removeAll(where: { $0.id == chat.id })
                chats.insert(chat, at: 0)
                upsertWorkspaceChat(chat)
                isCreatingSearchChat = false
                return
            }

            draftKind = nil
            pendingDraftChatState = nil
            composer = ""
            composerAttachments = []

            chats.removeAll(where: { $0.id == chat.id })
            chats.insert(chat, at: 0)
            upsertWorkspaceChat(chat)

            selectedItem = .chat(chat.id)
            selectedSearch = nil

            await refreshCollections(preferredSelection: .chat(chat.id))
        } catch {
            errorMessage = normalizeAPIError(error)
            SybilLog.error(SybilLog.ui, "Create chat from search failed", error: error)
        }

        isCreatingSearchChat = false
    }

    private func runQuickQuestion(prompt: String, provider: Provider, model: String) async {
        let runID = UUID()
        quickQuestionRunID = runID
        quickQuestionError = nil
        quickQuestionSubmittedPrompt = prompt
        quickQuestionSubmittedProvider = provider
        quickQuestionSubmittedModel = model
        quickQuestionMessages = [
            Message(
                id: "temp-assistant-quick-\(UUID().uuidString)",
                createdAt: Date(),
                role: .assistant,
                content: "",
                name: nil
            )
        ]
        isQuickQuestionSending = true

        defer {
            if quickQuestionRunID == runID {
                quickQuestionTask = nil
                quickQuestionRunID = nil
                isQuickQuestionSending = false
            }
        }

        let streamStatus = CompletionStreamStatus()

        do {
            try await client().runCompletionStream(
                body: CompletionStreamRequest(
                    chatId: nil,
                    persist: false,
                    provider: provider,
                    model: model,
                    messages: [CompletionRequestMessage(role: .user, content: prompt)]
                )
            ) { [weak self] event in
                guard let self else { return }
                await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus)
            }

            if let streamError = await streamStatus.error() {
                throw APIError.httpError(statusCode: 502, message: streamError)
            }
        } catch {
            guard quickQuestionRunID == runID else {
                return
            }
            if isCancellation(error) {
                return
            }

            quickQuestionError = normalizeAPIError(error)
            SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
        }
    }

    private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
        switch event {
        case .meta:
            break

        case let .toolCall(payload):
            upsertQuickQuestionToolCallMessage(payload)

        case let .delta(payload):
            guard !payload.text.isEmpty else { return }
            mutateQuickQuestionAssistantMessage { existing in
                existing + payload.text
            }

        case let .done(payload):
            mutateQuickQuestionAssistantMessage { _ in
                payload.text
            }

        case let .error(payload):
            await streamStatus.setError(payload.message)

        case .ignored:
            break
        }
    }

    private func loadInitialData(using client: any SybilAPIClienting) async {
        isLoadingCollections = true
        errorMessage = nil

        do {
            async let workspaceItemsValue = client.listWorkspaceItems()
            async let activeRunsValue = client.getActiveRuns()
            let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)

            applyWorkspaceItems(nextWorkspaceItems)
            applyActiveRuns(nextActiveRuns)

            SybilLog.info(
                SybilLog.app,
                "Loaded collections: \(chats.count) chats, \(searches.count) searches"
            )

            do {
                let nextCatalog = try await client.listModels()
                self.modelCatalog = nextCatalog.providers
                syncModelSelectionWithServerCatalog()
                SybilLog.info(SybilLog.app, "Loaded model catalog for \(nextCatalog.providers.count) providers")
            } catch {
                SybilLog.error(SybilLog.app, "Model catalog load failed, using provider fallback models", error: error)
                errorMessage = "Loaded chats/searches, but failed to load models: \(normalizeAPIError(error))"
            }

            let nextSelection: SidebarSelection?
            if case .settings = selectedItem {
                nextSelection = .settings
            } else if let currentSelection = selectedItem,
                      hasSelection(currentSelection, chats: chats, searches: searches) {
                nextSelection = currentSelection
            } else {
                nextSelection = sidebarItems.first?.selection
            }

            selectedItem = nextSelection
            if nextSelection == nil {
                draftKind = .chat
            }

            if let nextSelection {
                switch nextSelection {
                case .settings:
                    selectedChat = nil
                    selectedSearch = nil
                case .chat, .search:
                    await refreshSelectionIfNeeded()
                }
            }
            attachToVisibleActiveRunIfNeeded()
        } catch {
            errorMessage = normalizeAPIError(error)
            SybilLog.error(SybilLog.app, "Initial data load failed", error: error)
        }

        isLoadingCollections = false
    }

    private func syncModelSelectionWithServerCatalog() {
        if !providerOptions.contains(provider), let firstProvider = providerOptions.first {
            provider = firstProvider
            settings.preferredProvider = firstProvider
        }

        if !providerModelOptions.contains(model), let first = providerModelOptions.first {
            model = first
            settings.preferredModelByProvider[provider] = first
        }

        if let preferred = settings.preferredModelByProvider[provider], providerModelOptions.contains(preferred) {
            model = preferred
        }

        if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first {
            quickQuestionProvider = firstProvider
            settings.quickQuestionPreferredProvider = firstProvider
        }

        if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first {
            quickQuestionModel = first
            settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first
        }

        if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider],
           quickQuestionProviderModelOptions.contains(preferred)
        {
            quickQuestionModel = preferred
        }

        settings.persist()
    }

    private func refreshCollections(
        preferredSelection: SidebarSelection?,
        refreshSelection: Bool = true,
        attachVisibleActiveRun: Bool = true
    ) async {
        isLoadingCollections = true

        do {
            let client = try client()
            async let workspaceItemsValue = client.listWorkspaceItems()
            async let activeRunsValue = client.getActiveRuns()
            let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)

            applyWorkspaceItems(nextWorkspaceItems)
            applyActiveRuns(nextActiveRuns)

            SybilLog.info(
                SybilLog.app,
                "Refreshed collections: \(chats.count) chats, \(searches.count) searches"
            )
            errorMessage = nil

            if draftKind != nil {
                if attachVisibleActiveRun {
                    attachToVisibleActiveRunIfNeeded()
                }
                isLoadingCollections = false
                return
            }

            if case .settings = selectedItem {
                isLoadingCollections = false
                return
            }

            if let preferredSelection,
               hasSelection(preferredSelection, chats: chats, searches: searches) {
                selectedItem = preferredSelection
            } else if let existing = selectedItem,
                      hasSelection(existing, chats: chats, searches: searches) {
                selectedItem = existing
            } else {
                selectedItem = sidebarItems.first?.selection
            }

            if refreshSelection, selectedItem != nil {
                await refreshSelectionIfNeeded()
            }
            if attachVisibleActiveRun {
                attachToVisibleActiveRunIfNeeded()
            }
        } catch {
            if isCancellation(error) {
                SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
            } else if shouldSuppressInactiveTransportError(error) {
                SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
            } else {
                errorMessage = normalizeAPIError(error)
                SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
            }
        }

        isLoadingCollections = false
    }

    private func startActiveRunPolling() {
        activeRunPollingTask?.cancel()
        activeRunPollingTask = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(3))
                guard !Task.isCancelled else {
                    return
                }
                guard let self else {
                    return
                }
                await self.refreshActiveRunsFromServer()
            }
        }
    }

    private func stopActiveRunPolling() {
        activeRunPollingTask?.cancel()
        activeRunPollingTask = nil
    }

    private func cancelActiveStreamAttachTasks() {
        for task in activeChatAttachTasks.values {
            task.cancel()
        }
        for task in activeSearchAttachTasks.values {
            task.cancel()
        }
        activeChatAttachTasks = [:]
        activeSearchAttachTasks = [:]
    }

    private func refreshActiveRunsFromServer(
        using providedClient: (any SybilAPIClienting)? = nil,
        attachVisibleActiveRun: Bool = true
    ) async {
        guard isAuthenticated, !isCheckingSession else {
            return
        }

        do {
            let apiClient: any SybilAPIClienting
            if let providedClient {
                apiClient = providedClient
            } else {
                apiClient = try client()
            }

            let activeRuns = try await apiClient.getActiveRuns()
            applyActiveRuns(activeRuns)

            if attachVisibleActiveRun {
                attachToVisibleActiveRunIfNeeded()
            }
        } catch {
            if shouldSuppressInactiveTransportError(error) {
                SybilLog.info(SybilLog.app, "Suppressing active-run refresh transport interruption while app is inactive")
            } else {
                SybilLog.warning(SybilLog.app, "Active-run refresh failed: \(SybilLog.describe(error))")
            }
        }
    }

    private func applyActiveRuns(_ activeRuns: ActiveRunsResponse) {
        serverActiveChatIDs = Set(activeRuns.chats)
        serverActiveSearchIDs = Set(activeRuns.searches)
    }

    private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
        workspaceItems = items
        chats = items.compactMap(\.chatSummary)
        searches = items.compactMap(\.searchSummary)
    }

    private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
        if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
            chats.remove(at: existingIndex)
            chats.insert(chat, at: moveToFront ? 0 : existingIndex)
        } else {
            chats.insert(chat, at: 0)
        }

        upsertWorkspaceChat(chat, moveToFront: moveToFront)

        if selectedChat?.id == chat.id {
            selectedChat?.title = chat.title
            selectedChat?.parentChatId = chat.parentChatId
            selectedChat?.titleGenerationPending = chat.titleGenerationPending
            selectedChat?.updatedAt = chat.updatedAt
            selectedChat?.starred = chat.starred
            selectedChat?.starredAt = chat.starredAt
            selectedChat?.initiatedProvider = chat.initiatedProvider
            selectedChat?.initiatedModel = chat.initiatedModel
            selectedChat?.lastUsedProvider = chat.lastUsedProvider
            selectedChat?.lastUsedModel = chat.lastUsedModel
        }
    }

    private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
        if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
            searches.remove(at: existingIndex)
            searches.insert(search, at: moveToFront ? 0 : existingIndex)
        } else {
            searches.insert(search, at: 0)
        }

        upsertWorkspaceSearch(search, moveToFront: moveToFront)

        if selectedSearch?.id == search.id {
            selectedSearch?.title = search.title
            selectedSearch?.query = search.query
            selectedSearch?.updatedAt = search.updatedAt
            selectedSearch?.starred = search.starred
            selectedSearch?.starredAt = search.starredAt
        }
    }

    private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
        upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
    }

    private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) {
        upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront)
    }

    private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
        if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
            workspaceItems.remove(at: existingIndex)
            if moveToFront {
                workspaceItems.insert(item, at: 0)
            } else {
                workspaceItems.insert(item, at: existingIndex)
            }
            return
        }

        workspaceItems.insert(item, at: 0)
    }

    private func chatFamilyRootID(for chatID: String) -> String {
        if let selectedChat, selectedChat.id == chatID, let parentChatID = selectedChat.parentChatId {
            return parentChatID
        }
        if let parentChatID = chats.first(where: { $0.id == chatID })?.parentChatId {
            return parentChatID
        }
        if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
            return parentChatID
        }
        return chatID
    }

    private func attachToVisibleActiveRunIfNeeded() {
        guard draftKind == nil else {
            return
        }

        switch selectedItem {
        case let .chat(chatID):
            guard serverActiveChatIDs.contains(chatID),
                  !localActiveChatIDs.contains(chatID),
                  activeChatAttachTasks[chatID] == nil else {
                return
            }

            activeChatAttachTasks[chatID] = Task { [weak self] in
                await self?.attachToActiveChatStream(chatID: chatID)
            }

        case let .search(searchID):
            guard serverActiveSearchIDs.contains(searchID),
                  !localActiveSearchIDs.contains(searchID),
                  activeSearchAttachTasks[searchID] == nil else {
                return
            }

            activeSearchAttachTasks[searchID] = Task { [weak self] in
                await self?.attachToActiveSearchStream(searchID: searchID)
            }

        case .settings, nil:
            return
        }
    }

    private func attachToActiveChatStream(chatID: String) async {
        defer {
            activeChatAttachTasks[chatID] = nil
        }

        let selection = SidebarSelection.chat(chatID)

        do {
            let client = try client()

            if pendingChatStates[chatID] == nil {
                let baseChat: ChatDetail
                if let currentChat = currentSelectedChat, currentChat.id == chatID {
                    baseChat = currentChat
                } else {
                    baseChat = try await client.getChat(chatID: chatID)
                    if selectedItem == selection, draftKind == nil {
                        selectedChat = baseChat
                        selectedSearch = nil
                    }
                }

                pendingChatStates[chatID] = PendingChatState(
                    chatID: chatID,
                    messages: baseChat.messages + [
                        Message(
                            id: "temp-assistant-attach-\(UUID().uuidString)",
                            createdAt: Date(),
                            role: .assistant,
                            content: "",
                            name: nil
                        )
                    ]
                )
            }

            let streamStatus = CompletionStreamStatus()
            try await client.attachCompletionStream(chatID: chatID) { [weak self] event in
                guard let self else { return }
                await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus)
            }

            if let streamError = await streamStatus.error() {
                throw APIError.httpError(statusCode: 502, message: streamError)
            }

            serverActiveChatIDs.remove(chatID)
            pendingChatStates[chatID] = nil
            await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)

            if selectedItem == selection, draftKind == nil {
                selectedChat = try await client.getChat(chatID: chatID)
                selectedSearch = nil
            }
        } catch {
            serverActiveChatIDs.remove(chatID)
            pendingChatStates[chatID] = nil

            if isCancellation(error) {
                return
            }

            if isActiveStreamNotFound(error) {
                SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
            } else if shouldSuppressInactiveTransportError(error) {
                SybilLog.info(SybilLog.app, "Suppressing active chat stream transport interruption while app is inactive")
            } else {
                if selectedItem == selection, draftKind == nil {
                    errorMessage = normalizeAPIError(error)
                }
                SybilLog.error(SybilLog.app, "Active chat stream attach failed", error: error)
            }

            if selectedItem == selection, draftKind == nil {
                do {
                    selectedChat = try await client().getChat(chatID: chatID)
                    selectedSearch = nil
                } catch {
                    SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
                }
            }

            await refreshActiveRunsFromServer(attachVisibleActiveRun: false)
        }
    }

    private func attachToActiveSearchStream(searchID: String) async {
        defer {
            activeSearchAttachTasks[searchID] = nil
        }

        let selection = SidebarSelection.search(searchID)

        do {
            let client = try client()

            if currentSelectedSearch?.id != searchID {
                let search = try await client.getSearch(searchID: searchID)
                activeSearchDetails[searchID] = search
                if selectedItem == selection, draftKind == nil {
                    selectedSearch = search
                    selectedChat = nil
                }
            } else if let currentSearch = currentSelectedSearch {
                activeSearchDetails[searchID] = currentSearch
            }

            let streamStatus = SearchStreamStatus()
            try await client.attachSearchStream(searchID: searchID) { [weak self] event in
                guard let self else { return }
                await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
            }

            if let streamError = await streamStatus.error() {
                throw APIError.httpError(statusCode: 502, message: streamError)
            }

            serverActiveSearchIDs.remove(searchID)
            activeSearchDetails[searchID] = nil
            await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)

            if selectedItem == selection, draftKind == nil {
                selectedSearch = try await client.getSearch(searchID: searchID)
                selectedChat = nil
            }
        } catch {
            serverActiveSearchIDs.remove(searchID)
            activeSearchDetails[searchID] = nil

            if isCancellation(error) {
                return
            }

            if isActiveStreamNotFound(error) {
                SybilLog.info(SybilLog.app, "Active search stream \(searchID) no longer exists")
            } else if shouldSuppressInactiveTransportError(error) {
                SybilLog.info(SybilLog.app, "Suppressing active search stream transport interruption while app is inactive")
            } else {
                if selectedItem == selection, draftKind == nil {
                    errorMessage = normalizeAPIError(error)
                }
                SybilLog.error(SybilLog.app, "Active search stream attach failed", error: error)
            }

            if selectedItem == selection, draftKind == nil {
                do {
                    selectedSearch = try await client().getSearch(searchID: searchID)
                    selectedChat = nil
                } catch {
                    SybilLog.warning(SybilLog.app, "Search refresh after attach failure failed: \(SybilLog.describe(error))")
                }
            }

            await refreshActiveRunsFromServer(attachVisibleActiveRun: false)
        }
    }

    private func resetSelectionLoading() {
        selectionTask?.cancel()
        selectionTask = nil
        isLoadingSelection = false
    }

    private func requestChatBottomPin() {
        chatBottomPinRequestID += 1
    }

    private func startSelectionRefreshTask() -> Task<Void, Never> {
        isLoadingSelection = true
        let task = Task { [weak self] in
            guard let self else {
                return
            }
            await self.refreshSelectionIfNeeded()
        }
        selectionTask = task
        return task
    }

    private func waitForSelectionLoad(timeout: Duration) async {
        let clock = ContinuousClock()
        let deadline = clock.now.advanced(by: timeout)

        while isLoadingSelection, clock.now < deadline {
            try? await Task.sleep(for: .milliseconds(10))
        }
    }

    private func needsSelectionLoad(_ selection: SidebarSelection) -> Bool {
        switch selection {
        case let .chat(chatID):
            return selectedChat?.id != chatID
        case let .search(searchID):
            return selectedSearch?.id != searchID
        case .settings:
            return false
        }
    }

    private func refreshSelectionIfNeeded() async {
        guard let target = selectedItem else {
            selectedChat = nil
            selectedSearch = nil
            isLoadingSelection = false
            return
        }

        guard case .settings = target else {
            isLoadingSelection = true
            do {
                let client = try client()
                switch target {
                case let .chat(chatID):
                    SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
                    let isSelectingDifferentChat = selectedChat?.id != chatID
                    let chat = try await client.getChat(chatID: chatID)
                    guard selectedItem == target, draftKind == nil else {
                        return
                    }
                    selectedChat = chat
                    selectedSearch = nil
                    if isSelectingDifferentChat {
                        requestChatBottomPin()
                    }

                    if let provider = chat.lastUsedProvider,
                       let model = chat.lastUsedModel,
                       !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                        self.provider = provider
                        self.model = model
                    }

                case let .search(searchID):
                    SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
                    let search = try await client.getSearch(searchID: searchID)
                    guard selectedItem == target, draftKind == nil else {
                        return
                    }
                    selectedSearch = search
                    selectedChat = nil

                case .settings:
                    break
                }
                errorMessage = nil
            } catch {
                if isCancellation(error) {
                    SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
                } else if shouldSuppressInactiveTransportError(error) {
                    SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
                } else if selectedItem == target, draftKind == nil {
                    errorMessage = normalizeAPIError(error)
                    SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
                }
            }
            if selectedItem == target, draftKind == nil {
                isLoadingSelection = false
                selectionTask = nil
                attachToVisibleActiveRunIfNeeded()
            }
            return
        }

        selectedChat = nil
        selectedSearch = nil
        isLoadingSelection = false
    }

    private func sendChat(content: String, attachments: [ChatAttachment], sendContext: ActiveSendContext) async throws {
        let optimisticUser = Message(
            id: "temp-user-\(UUID().uuidString)",
            createdAt: Date(),
            role: .user,
            content: content,
            name: nil,
            metadata: SybilChatAttachmentSupport.metadataValue(for: attachments)
        )

        let optimisticAssistant = Message(
            id: "temp-assistant-\(UUID().uuidString)",
            createdAt: Date(),
            role: .assistant,
            content: "",
            name: nil
        )

        let optimisticMessages = (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]

        let client = try client()

        var chatID = currentChatID
        if let chatID {
            pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
        } else {
            pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
        }
        requestChatBottomPin()

        if chatID == nil {
            let created = try await client.createChat(title: nil)
            chatID = created.id
            let shouldShowCreatedChat = isSendContextVisible(sendContext)
            markSendContextInactive(sendContext)
            localActiveChatIDs.insert(created.id)

            chats.removeAll(where: { $0.id == created.id })
            chats.insert(created, at: 0)
            upsertWorkspaceChat(created)

            if shouldShowCreatedChat {
                draftKind = nil
                selectedItem = .chat(created.id)

                selectedChat = ChatDetail(
                    id: created.id,
                    title: created.title,
                    parentChatId: created.parentChatId,
                    titleGenerationPending: created.titleGenerationPending,
                    createdAt: created.createdAt,
                    updatedAt: created.updatedAt,
                    starred: created.starred,
                    starredAt: created.starredAt,
                    initiatedProvider: created.initiatedProvider,
                    initiatedModel: created.initiatedModel,
                    lastUsedProvider: created.lastUsedProvider,
                    lastUsedModel: created.lastUsedModel,
                    messages: []
                )
                selectedSearch = nil
            }

            SybilLog.info(SybilLog.app, "Created chat \(created.id)")
        }

        guard let chatID else {
            throw APIError.invalidResponse
        }

        localActiveChatIDs.insert(chatID)
        defer {
            localActiveChatIDs.remove(chatID)
        }

        if let draftPending = pendingDraftChatState {
            pendingDraftChatState = nil
            pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
            requestChatBottomPin()
        } else if pendingChatStates[chatID] == nil {
            pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
        } else {
            pendingChatStates[chatID]?.chatID = chatID
        }

        let baseChat: ChatDetail
        if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
            baseChat = selectedChat
        } else {
            baseChat = try await client.getChat(chatID: chatID)
        }

        let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !selectedModel.isEmpty else {
            throw APIError.invalidResponse
        }

        let requestMessages: [CompletionRequestMessage] =
            baseChat.messages
            .filter { !$0.isToolCallLog }
            .map {
                CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name, attachments: $0.attachments.isEmpty ? nil : $0.attachments)
            } + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]

        let streamStatus = CompletionStreamStatus()
        let streamLifecycleGeneration = appLifecycleGeneration
        let streamStartedWhileInactive = !isAppActive

        if shouldRequestChatTitle(baseChat) {
            Task { [weak self] in
                guard let self else { return }
                do {
                    let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
                    let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
                    await MainActor.run {
                        self.applyChatSummary(updated, moveToFront: false)
                    }
                } catch {
                    SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
                }
            }
        }

        let chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") {
            SybilLog.warning(SybilLog.app, "Chat response background time expired")
        }
        defer {
            chatBackgroundTask?.end()
        }

        do {
            try await client.runCompletionStream(
                body: CompletionStreamRequest(
                    chatId: chatID,
                    provider: provider,
                    model: selectedModel,
                    messages: requestMessages
                )
            ) { [weak self] event in
                guard let self else { return }
                await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus)
            }
        } catch {
            if shouldSuppressLifecycleTransportError(
                error,
                startedAt: streamLifecycleGeneration,
                startedWhileInactive: streamStartedWhileInactive
            ) {
                SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
                pendingChatStates[chatID] = nil
                if isAppActive {
                    await refreshInterruptedStream(preferredSelection: .chat(chatID))
                }
                return
            }

            throw error
        }

        if let streamError = await streamStatus.error() {
            throw APIError.httpError(statusCode: 502, message: streamError)
        }

        guard isAppActive else {
            pendingChatStates[chatID] = nil
            return
        }

        let sentChatSelection = SidebarSelection.chat(chatID)
        let shouldKeepSentChatSelected = selectedItem == sentChatSelection && draftKind == nil
        await refreshCollections(
            preferredSelection: shouldKeepSentChatSelected ? sentChatSelection : selectedItem,
            refreshSelection: false
        )

        guard selectedItem == sentChatSelection, draftKind == nil else {
            pendingChatStates[chatID] = nil
            return
        }

        do {
            let refreshedChat = try await client.getChat(chatID: chatID)
            guard selectedItem == sentChatSelection, draftKind == nil else {
                pendingChatStates[chatID] = nil
                return
            }
            selectedChat = refreshedChat
        } catch {
            if shouldSuppressLifecycleTransportError(
                error,
                startedAt: streamLifecycleGeneration,
                startedWhileInactive: streamStartedWhileInactive
            ) {
                SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change")
                pendingChatStates[chatID] = nil
                if isAppActive {
                    await refreshInterruptedStream(preferredSelection: .chat(chatID))
                }
                return
            }

            throw error
        }
        pendingChatStates[chatID] = nil
    }

    private func applyCompletionEvent(_ event: CompletionStreamEvent, chatID: String, streamStatus: CompletionStreamStatus) async {
        switch event {
        case let .meta(payload):
            if payload.chatId == chatID {
                pendingChatStates[chatID]?.chatID = payload.chatId
            }

        case let .toolCall(payload):
            upsertPendingToolCallMessage(payload, chatID: chatID)

        case let .delta(payload):
            guard !payload.text.isEmpty else { return }
            mutatePendingAssistantMessage(chatID: chatID) { existing in
                existing + payload.text
            }

        case let .done(payload):
            mutatePendingAssistantMessage(chatID: chatID) { _ in
                payload.text
            }

        case let .error(payload):
            await streamStatus.setError(payload.message)

        case .ignored:
            break
        }
    }

    private func sendSearch(query: String, sendContext: ActiveSendContext) async throws {
        let client = try client()

        var searchID = currentSearchID
        if searchID == nil {
            let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
            searchID = created.id
            let shouldShowCreatedSearch = isSendContextVisible(sendContext)
            markSendContextInactive(sendContext)
            localActiveSearchIDs.insert(created.id)

            searches.removeAll(where: { $0.id == created.id })
            searches.insert(created, at: 0)
            upsertWorkspaceSearch(created)

            if shouldShowCreatedSearch {
                draftKind = nil
                selectedItem = .search(created.id)
            }

            SybilLog.info(SybilLog.app, "Created search \(created.id)")
        }

        guard let searchID else {
            throw APIError.invalidResponse
        }

        localActiveSearchIDs.insert(searchID)
        defer {
            localActiveSearchIDs.remove(searchID)
        }

        let now = Date()
        let optimisticSearch = SearchDetail(
            id: searchID,
            title: String(query.prefix(80)),
            query: query,
            createdAt: currentSelectedSearch?.createdAt ?? now,
            updatedAt: now,
            starred: currentSelectedSearch?.starred ?? false,
            starredAt: currentSelectedSearch?.starredAt,
            requestId: nil,
            latencyMs: nil,
            error: nil,
            answerText: nil,
            answerRequestId: nil,
            answerCitations: nil,
            answerError: nil,
            results: []
        )
        activeSearchDetails[searchID] = optimisticSearch

        if selectedItem == .search(searchID), draftKind == nil {
            selectedSearch = optimisticSearch
        }

        let streamStatus = SearchStreamStatus()
        let streamLifecycleGeneration = appLifecycleGeneration
        let streamStartedWhileInactive = !isAppActive

        do {
            try await client.runSearchStream(
                searchID: searchID,
                body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
            ) { [weak self] event in
                guard let self else { return }
                await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
            }
        } catch {
            if shouldSuppressLifecycleTransportError(
                error,
                startedAt: streamLifecycleGeneration,
                startedWhileInactive: streamStartedWhileInactive
            ) {
                SybilLog.info(SybilLog.app, "Suppressing search stream transport interruption after app lifecycle change")
                activeSearchDetails[searchID] = nil
                if isAppActive {
                    await refreshInterruptedStream(preferredSelection: .search(searchID))
                }
                return
            }

            activeSearchDetails[searchID] = nil
            throw error
        }

        if let streamError = await streamStatus.error() {
            activeSearchDetails[searchID] = nil
            throw APIError.httpError(statusCode: 502, message: streamError)
        }

        guard isAppActive else {
            activeSearchDetails[searchID] = nil
            return
        }

        let sentSearchSelection = SidebarSelection.search(searchID)
        let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
        await refreshCollections(
            preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
            refreshSelection: false
        )
        activeSearchDetails[searchID] = nil
    }

    private func applySearchEvent(
        _ event: SearchStreamEvent,
        searchID: String,
        streamStatus: SearchStreamStatus
    ) async {
        switch event {
        case let .searchResults(payload):
            guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
                return
            }
            search.requestId = payload.requestId ?? search.requestId
            search.error = nil
            search.results = payload.results
            setActiveSearch(search, searchID: searchID)

        case let .searchError(payload):
            guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
                return
            }
            search.error = payload.error
            setActiveSearch(search, searchID: searchID)

        case let .answer(payload):
            guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
                return
            }
            search.answerText = payload.answerText
            search.answerRequestId = payload.answerRequestId
            search.answerCitations = payload.answerCitations
            search.answerError = nil
            setActiveSearch(search, searchID: searchID)

        case let .answerError(payload):
            guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
                return
            }
            search.answerError = payload.error
            setActiveSearch(search, searchID: searchID)

        case let .done(payload):
            setActiveSearch(payload.search, searchID: searchID)

        case let .error(payload):
            await streamStatus.setError(payload.message)

        case .ignored:
            break
        }
    }

    private func matchingSelectedSearch(_ searchID: String) -> SearchDetail? {
        guard let current = currentSelectedSearch, current.id == searchID else {
            return nil
        }
        return current
    }

    private func setActiveSearch(_ search: SearchDetail, searchID: String) {
        activeSearchDetails[searchID] = search
        if selectedItem == .search(searchID), draftKind == nil {
            selectedSearch = search
            selectedChat = nil
        }
    }

    private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
        guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
            return
        }

        let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
        guard let index else {
            return
        }

        var message = pending.messages[index]
        message.content = transform(message.content)
        pending.messages[index] = message
        pendingChatStates[chatID] = pending
    }

    private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
        let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
        guard let index else {
            return
        }

        quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
    }

    private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
        guard var pending = pendingChatStates[chatID] else {
            return
        }

        if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
            pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
            pendingChatStates[chatID] = pending
            return
        }

        let message = toolCallMessage(for: payload)

        if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
            pending.messages.insert(message, at: assistantIndex)
        } else {
            pending.messages.append(message)
        }

        pendingChatStates[chatID] = pending
    }

    private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
        if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
            quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
            return
        }

        let message = toolCallMessage(for: payload)
        if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) {
            quickQuestionMessages.insert(message, at: assistantIndex)
        } else {
            quickQuestionMessages.append(message)
        }
    }

    private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
        var metadataObject: [String: JSONValue] = [
            "kind": .string("tool_call"),
            "toolCallId": .string(payload.toolCallId),
            "toolName": .string(payload.name),
            "status": .string(payload.status),
            "summary": .string(payload.summary),
            "args": .object(payload.args),
            "startedAt": .string(payload.startedAt),
            "error": payload.error.map { .string($0) } ?? .null,
            "resultPreview": payload.resultPreview.map { .string($0) } ?? .null
        ]

        if let completedAt = payload.completedAt {
            metadataObject["completedAt"] = .string(completedAt)
        }
        if let durationMs = payload.durationMs {
            metadataObject["durationMs"] = .number(Double(durationMs))
        }

        let metadata: JSONValue = .object(metadataObject)

        let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            ? "Ran tool '\(payload.name)'."
            : payload.summary

        return Message(
            id: id ?? "temp-tool-\(payload.toolCallId)",
            createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
            role: .tool,
            content: summary,
            name: payload.name,
            metadata: metadata
        )
    }

    private func toolCallDate(from value: String?) -> Date? {
        guard let value else { return nil }
        let fractionalFormatter = ISO8601DateFormatter()
        fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let date = fractionalFormatter.date(from: value) {
            return date
        }

        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime]
        return formatter.date(from: value)
    }

    private var currentChatID: String? {
        if draftKind == .chat {
            return nil
        }
        if case let .chat(chatID) = selectedItem {
            return chatID
        }
        return nil
    }

    private var currentSelectedChat: ChatDetail? {
        guard case let .chat(chatID) = selectedItem,
              selectedChat?.id == chatID else {
            return nil
        }
        return selectedChat
    }

    private var currentSelectedSearch: SearchDetail? {
        guard case let .search(searchID) = selectedItem,
              selectedSearch?.id == searchID else {
            return nil
        }
        return selectedSearch
    }

    private var currentSearchID: String? {
        if draftKind == .search {
            return nil
        }
        if case let .search(searchID) = selectedItem {
            return searchID
        }
        return nil
    }

    private var currentSendContext: ActiveSendContext {
        if isSearchMode {
            if let searchID = currentSearchID {
                return .search(searchID)
            }
            return .draftSearch(draftIdentity)
        }

        if let chatID = currentChatID {
            return .chat(chatID)
        }
        return .draftChat(draftIdentity)
    }

    private func isSendContextActive(_ context: ActiveSendContext) -> Bool {
        switch context {
        case let .draftChat(identity):
            return activeDraftSendContexts.contains(.draftChat(identity))
        case let .chat(chatID):
            return activeChatIDs.contains(chatID)
        case let .draftSearch(identity):
            return activeDraftSendContexts.contains(.draftSearch(identity))
        case let .search(searchID):
            return activeSearchIDs.contains(searchID)
        }
    }

    private func markSendContextActive(_ context: ActiveSendContext) {
        switch context {
        case .draftChat, .draftSearch:
            activeDraftSendContexts.insert(context)
        case let .chat(chatID):
            localActiveChatIDs.insert(chatID)
        case let .search(searchID):
            localActiveSearchIDs.insert(searchID)
        }
    }

    private func markSendContextInactive(_ context: ActiveSendContext) {
        switch context {
        case .draftChat, .draftSearch:
            activeDraftSendContexts.remove(context)
        case let .chat(chatID):
            localActiveChatIDs.remove(chatID)
        case let .search(searchID):
            localActiveSearchIDs.remove(searchID)
        }
    }

    private func clearPendingChatState(for context: ActiveSendContext) {
        switch context {
        case .draftChat:
            pendingDraftChatState = nil
        case let .chat(chatID):
            pendingChatStates[chatID] = nil
        case .draftSearch, .search:
            break
        }
    }

    private func isSendContextVisible(_ context: ActiveSendContext) -> Bool {
        switch context {
        case let .draftChat(identity):
            return draftKind == .chat && draftIdentity == identity
        case let .chat(chatID):
            return selectedItem == .chat(chatID)
        case let .draftSearch(identity):
            return draftKind == .search && draftIdentity == identity
        case let .search(searchID):
            return selectedItem == .search(searchID)
        }
    }

    private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
        switch selection {
        case let .chat(chatID):
            return chats.contains(where: { $0.id == chatID })
        case let .search(searchID):
            return searches.contains(where: { $0.id == searchID })
        case .settings:
            return true
        }
    }

    private func displayableMessages(_ messages: [Message]) -> [Message] {
        messages.filter { $0.role != .system }
    }

    private func chatTitle(title: String?, messages: [Message]?) -> String {
        if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
            return title
        }

        if let firstUserMessage = messages?.first(where: { $0.role == .user })?.content.trimmingCharacters(in: .whitespacesAndNewlines),
           !firstUserMessage.isEmpty {
            return String(firstUserMessage.prefix(48))
        }

        if let firstUserMessage = messages?.first(where: { $0.role == .user }) {
            let attachmentSummary = SybilChatAttachmentSupport.attachmentSummary(firstUserMessage.attachments)
            if !attachmentSummary.isEmpty {
                return String(attachmentSummary.prefix(48))
            }
        }

        return "New chat"
    }

    private func searchTitle(title: String?, query: String?) -> String {
        if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
            return title
        }

        if let query = query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
            return String(query.prefix(64))
        }

        return "New search"
    }

    private func normalizeAuthError(_ error: Error) -> String {
        let normalized = normalizeAPIError(error)
        if normalized.contains("missing bearer token") || normalized.contains("invalid bearer token") {
            return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env."
        }
        return normalized
    }

    private func normalizeAPIError(_ error: Error) -> String {
        if let apiError = error as? APIError {
            switch apiError {
            case .invalidBaseURL:
                return "Set a valid API URL in Settings."

            case let .httpError(_, message):
                return message

            case let .networkError(message):
                return appendLoopbackHintIfNeeded(to: message)

            case let .decodingError(message):
                return message

            case .invalidResponse:
                return "Unexpected server response."

            case .noResponseStream:
                return "No response stream from server."
            }
        }

        if let decodingError = error as? DecodingError {
            return "Failed to decode server response: \(SybilLog.describe(decodingError))"
        }

        if let urlError = error as? URLError {
            let base = "Network error \(urlError.code.rawValue): \(urlError.localizedDescription)"
            return appendLoopbackHintIfNeeded(to: base)
        }

        if error is CancellationError {
            return "Request was cancelled."
        }

        return (error as NSError).localizedDescription
    }

    private func appendLoopbackHintIfNeeded(to message: String) -> String {
        guard let baseURL = settings.normalizedAPIBaseURL,
              let host = baseURL.host?.lowercased(),
              host == "127.0.0.1" || host == "localhost" else {
            return message
        }

        #if targetEnvironment(simulator)
        return message
        #else
        return message + " On physical devices, localhost/127.0.0.1 points to the phone. Use your Mac's LAN IP in Settings."
        #endif
    }

    private func refreshInterruptedStream(preferredSelection: SidebarSelection) async {
        try? await Task.sleep(for: .milliseconds(150))
        await refreshCollections(preferredSelection: preferredSelection)
    }

    private func shouldSuppressLifecycleTransportError(
        _ error: Error,
        startedAt generation: Int,
        startedWhileInactive: Bool
    ) -> Bool {
        guard generation != appLifecycleGeneration || startedWhileInactive || !isAppActive else {
            return false
        }

        return isTransientTransportInterruption(error)
    }

    private func shouldSuppressInactiveTransportError(_ error: Error) -> Bool {
        !isAppActive && isTransientTransportInterruption(error)
    }

    private func isTransientTransportInterruption(_ error: Error) -> Bool {
        if isCancellation(error) {
            return true
        }

        if let apiError = error as? APIError,
           case let .networkError(message) = apiError {
            let lowercased = message.lowercased()
            return lowercased.contains("network error -999")
                || lowercased.contains("network error -1005")
                || lowercased.contains("network connection was lost")
                || lowercased.contains("software caused connection abort")
                || lowercased.contains("socket is not connected")
        }

        let nsError = error as NSError
        if nsError.domain == NSURLErrorDomain {
            return nsError.code == URLError.cancelled.rawValue
                || nsError.code == URLError.networkConnectionLost.rawValue
                || nsError.code == URLError.notConnectedToInternet.rawValue
                || nsError.code == URLError.timedOut.rawValue
        }

        if nsError.domain == NSPOSIXErrorDomain {
            return nsError.code == 53 || nsError.code == 57
        }

        return false
    }

    private func isActiveStreamNotFound(_ error: Error) -> Bool {
        if let apiError = error as? APIError,
           case let .httpError(statusCode, _) = apiError {
            return statusCode == 404
        }
        return false
    }

    private func isCancellation(_ error: Error) -> Bool {
        if error is CancellationError {
            return true
        }
        if let urlError = error as? URLError, urlError.code == .cancelled {
            return true
        }
        return false
    }

    private func client() throws -> any SybilAPIClienting {
        guard let baseURL = settings.normalizedAPIBaseURL else {
            throw APIError.invalidBaseURL
        }

        SybilLog.debug(
            SybilLog.app,
            "Creating API client for \(baseURL.absoluteString) (token: \(settings.trimmedTokenOrNil == nil ? "none" : "set"))"
        )

        return clientFactory(
            APIConfiguration(
                baseURL: baseURL,
                authToken: settings.trimmedTokenOrNil
            )
        )
    }

    private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
        if chat.titleGenerationPending {
            return true
        }
        return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
    }
}
