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 initiatedLabel: String?
}

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

private enum ActiveSendContext: Equatable {
    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 selectedItem: SidebarSelection?
    var selectedChat: ChatDetail?
    var selectedSearch: SearchDetail?
    var draftKind: DraftKind?

    var isLoadingCollections = false
    var isLoadingSelection = false
    var isSending = false
    var isCreatingSearchChat = false
    var errorMessage: String?

    var composer = ""
    var composerAttachments: [ChatAttachment] = []
    var provider: Provider
    var modelCatalog: [Provider: ProviderModelInfo] = [:]
    var model: String

    @ObservationIgnored
    private var hasBootstrapped = false
    private var pendingChatState: PendingChatState?
    private var activeSendContext: ActiveSendContext?
    private var draftIdentity = UUID()
    @ObservationIgnored
    private var selectionTask: Task<Void, Never>?
    @ObservationIgnored
    private var chatBackgroundTask: SybilBackgroundTaskAssertion?
    @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"]
    ]

    init(
        settings: SybilSettingsStore = SybilSettingsStore(),
        clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
            SybilAPIClient(configuration: configuration)
        }
    ) {
        self.settings = settings
        self.clientFactory = clientFactory
        self.provider = settings.preferredProvider
        self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
    }

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

    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
    }

    var canSendComposer: Bool {
        if isSending {
            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 ?? [])
        guard let pending = pendingChatState else {
            return canonical
        }

        if let pendingID = pending.chatID {
            if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
                return displayableMessages(pending.messages)
            }
            return canonical
        }

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

        return canonical
    }

    var displayedSearch: SearchDetail? {
        currentSelectedSearch
    }

    var isSendingVisibleChat: Bool {
        guard isSending, pendingChatState != nil else {
            return false
        }

        switch activeSendContext {
        case let .draftChat(identity):
            return draftKind == .chat && identity == draftIdentity
        case let .chat(chatID):
            return selectedItem == .chat(chatID)
        case .draftSearch, .search, nil:
            return false
        }
    }

    var isRunningVisibleSearch: Bool {
        guard isSending else {
            return false
        }

        switch activeSendContext {
        case let .draftSearch(identity):
            return draftKind == .search && identity == draftIdentity
        case let .search(searchID):
            return selectedItem == .search(searchID)
        case .draftChat, .chat, nil:
            return false
        }
    }

    var sidebarItems: [SidebarItem] {
        let chatItems: [SidebarItem] = chats.map { chat in
            let initiatedLabel: String?
            if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
                if let provider = chat.initiatedProvider {
                    initiatedLabel = "\(provider.displayName) • \(model)"
                } else {
                    initiatedLabel = model
                }
            } else {
                initiatedLabel = nil
            }

            return SidebarItem(
                selection: .chat(chat.id),
                kind: .chat,
                title: chatTitle(title: chat.title, messages: nil),
                updatedAt: chat.updatedAt,
                initiatedLabel: initiatedLabel
            )
        }

        let searchItems: [SidebarItem] = searches.map { search in
            SidebarItem(
                selection: .search(search.id),
                kind: .search,
                title: searchTitle(title: search.title, query: search.query),
                updatedAt: search.updatedAt,
                initiatedLabel: "exa"
            )
        }

        return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
    }

    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()
        pendingChatState = nil
        activeSendContext = nil
        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)
        } catch {
            isAuthenticated = false
            authMode = nil
            chats = []
            searches = []
            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 startNewChat() {
        SybilLog.debug(SybilLog.ui, "Starting draft chat")
        resetSelectionLoading()
        draftIdentity = UUID()
        draftKind = .chat
        selectedItem = nil
        selectedChat = nil
        selectedSearch = nil
        pendingChatState = 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
        pendingChatState = nil
        errorMessage = nil
        composer = ""
        composerAttachments = []
    }

    func openSettings() {
        SybilLog.debug(SybilLog.ui, "Opening settings")
        resetSelectionLoading()
        draftKind = nil
        selectedItem = .settings
        selectedChat = nil
        selectedSearch = nil
        pendingChatState = 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
            pendingChatState = 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 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,
            "Foreground refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
        )

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

        if shouldRefreshSelection {
            await refreshSelectionIfNeeded()
        }
    }

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

        guard !isSending else {
            return
        }

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

        composer = ""
        composerAttachments = []
        errorMessage = nil
        activeSendContext = sendContext
        isSending = true

        do {
            if sendContext.isSearch {
                SybilLog.info(SybilLog.ui, "Sending search query")
                try await sendSearch(query: content)
            } else {
                SybilLog.info(SybilLog.ui, "Sending chat prompt")
                try await sendChat(content: content, attachments: attachments)
            }
        } catch {
            let shouldSurfaceError = isSendContextVisible(sendContext) || (activeSendContext.map { isSendContextVisible($0) } ?? false)
            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
            }
            pendingChatState = nil
        }

        isSending = false
        activeSendContext = nil
    }

    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, !isSending 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)
                isCreatingSearchChat = false
                return
            }

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

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

            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 loadInitialData(using client: any SybilAPIClienting) async {
        isLoadingCollections = true
        errorMessage = nil

        do {
            async let chatsValue = client.listChats()
            async let searchesValue = client.listSearches()
            let (nextChats, nextSearches) = try await (chatsValue, searchesValue)

            chats = nextChats
            searches = nextSearches

            SybilLog.info(
                SybilLog.app,
                "Loaded collections: \(nextChats.count) chats, \(nextSearches.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: nextChats, searches: nextSearches) {
                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()
                }
            }
        } catch {
            errorMessage = normalizeAPIError(error)
            SybilLog.error(SybilLog.app, "Initial data load failed", error: error)
        }

        isLoadingCollections = false
    }

    private func syncModelSelectionWithServerCatalog() {
        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
        }

        settings.persist()
    }

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

        do {
            let client = try client()
            async let chatsValue = client.listChats()
            async let searchesValue = client.listSearches()
            let (nextChats, nextSearches) = try await (chatsValue, searchesValue)

            chats = nextChats
            searches = nextSearches

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

            if draftKind != nil {
                isLoadingCollections = false
                return
            }

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

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

            if refreshSelection, selectedItem != nil {
                await refreshSelectionIfNeeded()
            }
        } catch {
            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 resetSelectionLoading() {
        selectionTask?.cancel()
        selectionTask = nil
        isLoadingSelection = false
    }

    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 chat = try await client.getChat(chatID: chatID)
                    guard selectedItem == target, draftKind == nil else {
                        return
                    }
                    selectedChat = chat
                    selectedSearch = nil

                    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
            }
            return
        }

        selectedChat = nil
        selectedSearch = nil
        isLoadingSelection = false
    }

    private func sendChat(content: String, attachments: [ChatAttachment]) 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
        )

        pendingChatState = PendingChatState(
            chatID: currentChatID,
            messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
        )

        let client = try client()

        var chatID = currentChatID
        if chatID == nil {
            let created = try await client.createChat(title: nil)
            chatID = created.id
            let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true
            activeSendContext = .chat(created.id)

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

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

                selectedChat = ChatDetail(
                    id: created.id,
                    title: created.title,
                    createdAt: created.createdAt,
                    updatedAt: created.updatedAt,
                    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
        }

        pendingChatState?.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 isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
            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.chats = self.chats.map { existing in
                            if existing.id == updated.id {
                                return updated
                            }
                            return existing
                        }

                        if self.selectedChat?.id == updated.id {
                            self.selectedChat?.title = updated.title
                            self.selectedChat?.updatedAt = updated.updatedAt
                        }
                    }
                } catch {
                    SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
                }
            }
        }

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

        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, streamStatus: streamStatus)
            }
        } catch {
            if shouldSuppressLifecycleTransportError(
                error,
                startedAt: streamLifecycleGeneration,
                startedWhileInactive: streamStartedWhileInactive
            ) {
                SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
                pendingChatState = 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 {
            pendingChatState = 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 {
            pendingChatState = nil
            return
        }

        do {
            let refreshedChat = try await client.getChat(chatID: chatID)
            guard selectedItem == sentChatSelection, draftKind == nil else {
                pendingChatState = 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")
                pendingChatState = nil
                if isAppActive {
                    await refreshInterruptedStream(preferredSelection: .chat(chatID))
                }
                return
            }

            throw error
        }
        pendingChatState = nil
    }

    private func applyCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
        switch event {
        case let .meta(payload):
            pendingChatState?.chatID = payload.chatId

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

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

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

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

        case .ignored:
            break
        }
    }

    private func sendSearch(query: String) 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 = activeSendContext.map { isSendContextVisible($0) } ?? true
            activeSendContext = .search(created.id)

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

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

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

        guard let searchID else {
            throw APIError.invalidResponse
        }

        let now = Date()
        if selectedItem == .search(searchID), draftKind == nil {
            selectedSearch = SearchDetail(
                id: searchID,
                title: String(query.prefix(80)),
                query: query,
                createdAt: currentSelectedSearch?.createdAt ?? now,
                updatedAt: now,
                requestId: nil,
                latencyMs: nil,
                error: nil,
                answerText: nil,
                answerRequestId: nil,
                answerCitations: nil,
                answerError: nil,
                results: []
            )
        }

        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")
                if isAppActive {
                    await refreshInterruptedStream(preferredSelection: .search(searchID))
                }
                return
            }

            throw error
        }

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

        guard isAppActive else {
            return
        }

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

    private func applySearchEvent(
        _ event: SearchStreamEvent,
        searchID: String,
        streamStatus: SearchStreamStatus
    ) async {
        guard let current = currentSelectedSearch, current.id == searchID else {
            if case let .done(payload) = event,
               selectedItem == .search(searchID),
               draftKind == nil {
                selectedSearch = payload.search
            }
            return
        }

        switch event {
        case let .searchResults(payload):
            selectedSearch?.requestId = payload.requestId ?? current.requestId
            selectedSearch?.error = nil
            selectedSearch?.results = payload.results

        case let .searchError(payload):
            selectedSearch?.error = payload.error

        case let .answer(payload):
            selectedSearch?.answerText = payload.answerText
            selectedSearch?.answerRequestId = payload.answerRequestId
            selectedSearch?.answerCitations = payload.answerCitations
            selectedSearch?.answerError = nil

        case let .answerError(payload):
            selectedSearch?.answerError = payload.error

        case let .done(payload):
            selectedSearch = payload.search
            selectedChat = nil

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

        case .ignored:
            break
        }
    }

    private func mutatePendingAssistantMessage(_ transform: (String) -> String) {
        guard var pending = pendingChatState, !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
        pendingChatState = pending
    }

    private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall) {
        guard var pending = pendingChatState else {
            return
        }

        if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
            return
        }

        let metadata: JSONValue = .object([
            "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),
            "completedAt": .string(payload.completedAt),
            "durationMs": .number(Double(payload.durationMs)),
            "error": payload.error.map { .string($0) } ?? .null,
            "resultPreview": payload.resultPreview.map { .string($0) } ?? .null
        ])

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

        let message = Message(
            id: "temp-tool-\(payload.toolCallId)",
            createdAt: Date(),
            role: .tool,
            content: summary,
            name: payload.name,
            metadata: metadata
        )

        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)
        }

        pendingChatState = pending
    }

    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 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 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 isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
        if let detail, detail.id == chatID {
            if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
                return false
            }
            return true
        }

        if let summary = chats.first(where: { $0.id == chatID }) {
            if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
                return false
            }
        }

        return true
    }
}
