//
//  ChatViewModel.swift
//  Confab
//
//  Main chat view model
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.


import Foundation
import os
import SwiftUI
import UniformTypeIdentifiers

#if os(macOS)
/// Backing object for the Save Chat accessory view's name field + folder picker. A plain
/// NSObject is needed here (rather than living on `ChatViewModel`) because `NSPopUpButton`/
/// `NSMenuItem` actions require an `@objc` target, and the `@Observable` `ChatViewModel` isn't one.
private final class ConversationSaveAccessory: NSObject {
    let container: NSView
    let nameField: NSTextField
    private let folderPopup: NSPopUpButton
    private var folders: [Folder]
    private var entries: [(folder: Folder, depth: Int)] = []  // recomputed in rebuildMenu whenever folders changes
    private var lastGoodSelection: UUID?  // the folder to revert to if "New Folder…" is cancelled

    init(defaultName: String, folders: [Folder], selectedFolderId: UUID?) {
        self.folders = folders
        self.lastGoodSelection = selectedFolderId
        container = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 58))
        nameField = NSTextField(frame: NSRect(x: 0, y: 30, width: 260, height: 24))
        folderPopup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
        super.init()

        nameField.placeholderString = "Conversation name…"
        nameField.stringValue = defaultName
        container.addSubview(nameField)

        rebuildMenu(selecting: selectedFolderId)
        folderPopup.target = self
        folderPopup.action = #selector(popupChanged)
        container.addSubview(folderPopup)
    }

    private func rebuildMenu(selecting folderId: UUID?) {
        entries = Folder.orderedTree(from: folders)
        folderPopup.removeAllItems()
        folderPopup.addItem(withTitle: "No Folder")
        for entry in entries {
            folderPopup.addItem(withTitle: String(repeating: "  ", count: entry.depth) + entry.folder.name)
        }
        folderPopup.menu?.addItem(.separator())
        folderPopup.addItem(withTitle: "New Folder…")

        if let folderId, let idx = entries.firstIndex(where: { $0.folder.id == folderId }) {
            folderPopup.selectItem(at: idx + 1)
        } else {
            folderPopup.selectItem(at: 0)
        }
    }

    @objc private func popupChanged() {
        let lastIndex = folderPopup.numberOfItems - 1
        guard folderPopup.indexOfSelectedItem == lastIndex else {
            lastGoodSelection = resolvedFolderId
            return
        }

        // "New Folder…" was picked — prompt inline, then create + select, or revert.
        let nameAlert = NSAlert()
        nameAlert.messageText = "New Folder"
        nameAlert.informativeText = "Enter a name for the new folder:"
        nameAlert.addButton(withTitle: "Create")
        nameAlert.addButton(withTitle: "Cancel")
        let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
        nameAlert.accessoryView = field
        nameAlert.window.initialFirstResponder = field

        guard nameAlert.runModal() == .alertFirstButtonReturn else {
            rebuildMenu(selecting: lastGoodSelection)
            return
        }
        let newName = field.stringValue.trimmingCharacters(in: .whitespaces)
        guard !newName.isEmpty, let created = try? DatabaseService.shared.createFolder(name: newName) else {
            rebuildMenu(selecting: lastGoodSelection)
            return
        }
        folders.append(created)
        rebuildMenu(selecting: created.id)
        lastGoodSelection = .some(created.id)
    }

    var resolvedFolderId: UUID? {
        let idx = folderPopup.indexOfSelectedItem
        guard idx >= 1, idx - 1 < entries.count else { return nil }
        return entries[idx - 1].folder.id
    }
}
#endif

@Observable
@MainActor
class ChatViewModel {
    // MARK: - Observable State

    var messages: [Message] = []
    var inputText: String = ""
    var isGenerating: Bool = false
    /// Live "what's happening right now" line shown under ProcessingIndicator's thinking verb
    /// while a tool-calling loop is running — e.g. "🔧 Calling: read_file". Replaced in place each
    /// round rather than appending a new chat message, so a long multi-round tool chain doesn't
    /// stack up a growing list of rows. Not persisted; nil whenever no tool round is in flight. The
    /// full chain is still recorded, just collapsed into one expandable summary message once the
    /// loop finishes — see generateAIResponseWithTools's use of allToolCallDetails.
    var currentToolActivity: String? = nil
    var sessionStats = SessionStats()
    var selectedModel: ModelInfo?
    var currentProvider: Settings.Provider = .openrouter
    var onlineMode: Bool = false
    var memoryEnabled: Bool = true
    var mcpEnabled: Bool = false
    var mcpStatus: String? = nil
    var availableModels: [ModelInfo] = []
    var isLoadingModels: Bool = false
    var showConversations: Bool = false
    var showModelSelector: Bool = false
    var showSettings: Bool = false
    var showStats: Bool = false
    var showHelp: Bool = false
    var showCredits: Bool = false
    var showHistory: Bool = false
    var releaseNotesRequest: ReleaseNotesRequest? = nil
    var showShortcuts: Bool = false
    var showSkills: Bool = false
    var showJarvis: Bool = false
    var modelInfoTarget: ModelInfo? = nil
    var commandHistory: [String] = []
    var historyIndex: Int = 0
    private var silentContinuePrompt: String? = nil

    // Save tracking
    var currentConversationId: UUID? = nil
    var currentConversationName: String? = nil
    private var savedMessageCount: Int = 0

    // Per-conversation notes.md (see ConversationNotesService)
    var notesEnabled: Bool = false
    var notesFilename: String? = nil

    var hasUnsavedChanges: Bool {
        let chatCount = messages.filter { $0.role != .system }.count
        return chatCount > 0 && chatCount != savedMessageCount
    }

    // MARK: - Crash-Recovery Draft

    private var draftTimer: Timer?
    private var lastDraftFingerprint: Int?
    private var hasCheckedForCrashRecoveryDraft = false

    // MARK: - Private State

    private var streamingTask: Task<Void, Never>?
    private let settings = SettingsService.shared
    private let providerRegistry = ProviderRegistry.shared

    // Default system prompt - generic for all models
    private let defaultSystemPrompt = """
You are a helpful AI assistant. Follow these core principles:

## CORE BEHAVIOR

- **Accuracy First**: Never invent information. If unsure, say so clearly.
- **Ask for Clarification**: When ambiguous, ask questions before proceeding.
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
- **Complete Tasks Properly**: If you start something, finish it correctly.
- **Match the User's Language**: Always reply in the same language the user is writing in, even when the request itself involves another language (e.g. a translation or spelling request) — the target language applies to the content you produce, not to your own reply.

## FORMATTING

Always use Markdown formatting:
- **Bold** for emphasis
- Code blocks with language tags: ```python
- Headings (##, ###) for structure
- Lists for organization

## HONESTY

It's better to admit "I need more information" or "I cannot do that" than to fake completion or invent answers.
"""

    // Tool-specific instructions (added when tools are available)
    private let toolUsageGuidelines = """

## TOOL USAGE (You have access to tools)

**CRITICAL: Never claim to have done something without actually using the tools.**

**BAD Examples (NEVER do this):**
❌ "I've fixed the issue" → No tool calls shown
❌ "The file has been updated" → Didn't actually use the tool
❌ "Done!" → Claimed completion without evidence

**GOOD Examples (ALWAYS do this):**
✅ [Uses tool silently] → Then explain what you did
✅ Shows actual work through tool calls
✅ If you can't complete: "I found the issue, but need clarification..."

**ENFORCEMENT:**
- If a task requires using a tool → YOU MUST actually use it
- Don't just describe what you would do - DO IT
- The user can see your tool calls - they are proof you did the work
- NEVER say "fixed" or "done" without showing tool usage

**EFFICIENCY:**
- Some models have tool call limits (~25-30 per request)
- Make comprehensive changes in fewer tool calls when possible
- If approaching limits, tell the user you need to continue in phases
- Don't use tools to "verify" your work unless asked - trust your edits

**FILE EDITING — CRITICAL:**
- NEVER use write_file to rewrite a large existing file (>200 lines or >8KB)
- Large write_file calls exceed output token limits and will fail — the content will be truncated
- For existing files: ALWAYS use edit_file to replace specific sections
- Use write_file ONLY for new files or very small files (<100 lines)
- If you need to make many changes to a large file, use multiple edit_file calls

**METHODOLOGY:**
1. Use the tool to gather information (if needed)
2. Use the tool to make changes (if needed)
3. Explain what you did and why

Don't narrate future actions ("Let me...") - just use the tools.
"""

    /// Builds the complete system prompt by combining default + conditional sections + custom
    private var effectiveSystemPrompt: String {
        // Tool guidance, the user's custom prompt, and Agent Skills all generally assume tool
        // access and can easily blow past small context windows (e.g. Apple's on-device 4K
        // limit) — skip all three for tool-incapable models.
        let modelSupportsTools = selectedModel?.capabilities.tools ?? true

        // Check if user wants to replace the default prompt entirely (BYOP mode)
        if modelSupportsTools,
           settings.customPromptMode == .replace,
           let customPrompt = settings.systemPrompt,
           !customPrompt.isEmpty {
            // BYOP: use ONLY the custom prompt, but conversation notes are a non-overridable
            // instruction — they must survive even when the user has replaced everything else.
            return customPrompt + Self.buildNotesSection(body: currentNotesBody)
        }

        // Otherwise, build the prompt: default + conditional sections + custom (if append mode)
        var prompt = defaultSystemPrompt

        // Prepend model identity to prevent models trained on Claude data from misidentifying themselves.
        // Skip for direct Anthropic/OpenAI providers — those models know who they are.
        if let model = selectedModel,
           currentProvider != .anthropic && currentProvider != .openai {
            prompt = "You are \(model.name).\n\n" + prompt
        }

        // Add tool-specific guidelines if MCP is enabled (tools are available)
        if mcpEnabled && modelSupportsTools {
            prompt += toolUsageGuidelines
        }

        // Append custom prompt if in append mode and custom prompt exists
        if modelSupportsTools,
           settings.customPromptMode == .append,
           let customPrompt = settings.systemPrompt,
           !customPrompt.isEmpty {
            prompt += "\n\n---\n\nAdditional Instructions:\n" + customPrompt
        }

        // Append active agent skills (SKILL.md-style behavioral instructions)
        if modelSupportsTools {
            let activeSkills = settings.agentSkills.filter { $0.isActive }
            if !activeSkills.isEmpty {
                prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n"
                for skill in activeSkills {
                    prompt += "### \(skill.name)\n\n\(skill.content)\n\n"
                    let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id)
                    if !files.isEmpty {
                        prompt += "**Skill Data Files:**\n\n"
                        for (name, content) in files {
                            let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
                            prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n"
                        }
                    }
                }
            }
        }

        // Append conversation notes (see ConversationNotesService). Deliberately outside the
        // modelSupportsTools gate above — fenced-block writes exist specifically so tool-incapable
        // models (e.g. Apple On-Device) can still use this.
        prompt += Self.buildNotesSection(body: currentNotesBody)

        return prompt
    }

    /// The current conversation's notes body if notes are enabled, nil otherwise. Empty string
    /// means notes are on but nothing has been written yet.
    private var currentNotesBody: String? {
        guard notesEnabled, let filename = notesFilename else { return nil }
        return ConversationNotesService.shared.readBody(filename: filename) ?? ""
    }

    /// Builds the "## Conversation Notes" system prompt section. Pure/testable: nil body means
    /// notes are off for this conversation and nothing is appended.
    nonisolated static func buildNotesSection(body: String?) -> String {
        guard let body else { return "" }
        return """


        ---

        ## Conversation Notes

        You maintain a persistent memory file for this specific conversation, saved outside the chat and re-read at the start of every turn. Use it for durable facts, preferences, or context worth keeping across the whole conversation — not a transcript, not every detail.

        To update it, include this block anywhere in your reply — it is invisible to the user and will not appear in the chat:

        ```update-notes
        <the complete new contents of the notes file>
        ```

        Only include the block when you actually want to change the notes. Each one replaces the previous contents entirely, so include everything worth keeping, not just what changed. If the user directly asks you to add, change, or remove something from the notes, comply using this same mechanism.

        Current notes:
        \(body.isEmpty ? "(empty — nothing saved yet)" : body)
        """
    }

    /// Detects a ```update-notes fenced block in a finalized assistant message, strips it from
    /// the text that will actually be displayed, and returns its body separately so it can be
    /// persisted via ConversationNotesService. Only call this once a message is fully finalized
    /// (never on in-flight streaming deltas) — stripping mid-stream would flash the block and
    /// then remove it.
    nonisolated static func extractNotesUpdate(from content: String) -> (display: String, notesBody: String?) {
        let pattern = #"```update-notes\s*\n([\s\S]*?)```"#
        guard let regex = try? NSRegularExpression(pattern: pattern),
              let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)),
              let bodyRange = Range(match.range(at: 1), in: content),
              let fullRange = Range(match.range(at: 0), in: content)
        else {
            return (content, nil)
        }

        let body = String(content[bodyRange]).trimmingCharacters(in: .whitespacesAndNewlines)
        var display = content
        display.removeSubrange(fullRange)
        display = display.trimmingCharacters(in: .whitespacesAndNewlines)
        return (display, body)
    }

    /// Applies extractNotesUpdate at message finalization: writes any extracted notes body to
    /// disk and returns the content with the fenced block stripped. No-op (returns content
    /// unchanged) unless notes are enabled and the conversation has an ID and filename.
    private func applyNotesUpdateIfNeeded(_ content: String) -> String {
        guard notesEnabled, let conversationId = currentConversationId, let filename = notesFilename else {
            return content
        }
        let (display, notesBody) = Self.extractNotesUpdate(from: content)
        if let notesBody {
            ConversationNotesService.shared.write(body: notesBody, filename: filename, conversationId: conversationId)
        }
        return display
    }

    // MARK: - Initialization
    
    init() {
        // Load settings
        self.currentProvider = settings.defaultProvider
        self.onlineMode = settings.onlineMode
        self.memoryEnabled = settings.memoryEnabled
        self.mcpEnabled = settings.mcpEnabled

        // Load command history from database
        if let history = try? DatabaseService.shared.loadCommandHistory() {
            self.commandHistory = history.map { $0.input }
            self.historyIndex = self.commandHistory.count
        }

        // Load models on startup
        Task {
            await loadAvailableModels()
        }

        startDraftTimer()
    }
    
    // MARK: - Public Methods

    /// Switch to a different provider (from header dropdown)
    func changeProvider(_ newProvider: Settings.Provider) {
        guard newProvider != currentProvider else { return }
        Log.ui.info("Switching provider to \(newProvider.rawValue)")
        settings.defaultProvider = newProvider
        currentProvider = newProvider
        selectedModel = nil
        availableModels = []
        Task { await loadAvailableModels() }
    }

    /// Start a new conversation — gated behind the unsaved-changes prompt if needed.
    func newConversation() {
        #if os(macOS)
        confirmDiscardIfNeeded(then: performNewConversation)
        #else
        performNewConversation()
        #endif
    }

    private func performNewConversation() {
        messages = []
        sessionStats = SessionStats()
        inputText = ""
        currentConversationId = nil
        currentConversationName = nil
        savedMessageCount = 0
        notesEnabled = false
        notesFilename = nil
    }

    /// Re-sync local state from SettingsService (called when Settings sheet dismisses)
    func syncFromSettings() {
        let newProvider = settings.defaultProvider
        let providerChanged = currentProvider != newProvider
        currentProvider = newProvider
        onlineMode = settings.onlineMode
        memoryEnabled = settings.memoryEnabled
        mcpEnabled = settings.mcpEnabled
        mcpStatus = mcpEnabled ? "MCP" : nil
        startDraftTimer()

        if providerChanged {
            selectedModel = nil
            availableModels = []
            Task { await loadAvailableModels() }
        }
    }

    func loadAvailableModels() async {
        isLoadingModels = true

        do {
            guard let provider = providerRegistry.getCurrentProvider() else {
                Log.ui.warning("No API key configured for current provider")
                isLoadingModels = false
                showSystemMessage("⚠️ No API key configured. Add your API key in Settings to load models.")
                return
            }

            let models = try await provider.listModels()
            availableModels = models

            // Select model priority: saved default > current selection > first available
            if let defaultModelId = settings.defaultModel,
               let defaultModel = models.first(where: { $0.id == defaultModelId }) {
                selectedModel = defaultModel
            } else if selectedModel == nil, let firstModel = models.first {
                selectedModel = firstModel
            }
            isLoadingModels = false

        } catch {
            Log.api.error("Failed to load models: \(error.localizedDescription)")
            isLoadingModels = false
            showSystemMessage("⚠️ Could not load models: \(error.localizedDescription)")
        }
    }
    
    func sendMessage() {
        guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }

        // If already generating, cancel first — new message becomes a followup in context
        if isGenerating { cancelGeneration() }

        let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)

        // Handle slash escape: "//" becomes "/"
        var effectiveInput = trimmedInput
        if effectiveInput.hasPrefix("//") {
            effectiveInput = String(effectiveInput.dropFirst())
        } else if effectiveInput.hasPrefix("/") {
            // Check if it's a slash command
            handleCommand(effectiveInput)
            inputText = ""
            return
        }
        
        // Parse file attachments
        let (cleanText, filePaths) = effectiveInput.parseFileAttachments()

        // Read file attachments from disk
        let attachments: [FileAttachment]? = filePaths.isEmpty ? nil : readFileAttachments(filePaths)

        // Create user message
        let userMessage = Message(
            role: .user,
            content: cleanText,
            tokens: cleanText.estimateTokens(),
            cost: nil,
            timestamp: Date(),
            attachments: attachments,
            modelId: selectedModel?.id
        )

        messages.append(userMessage)
        sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)

        // Persist the crash-recovery draft immediately rather than waiting for the next
        // periodic tick — otherwise a force-quit shortly after sending loses this message.
        persistDraftIfChanged()

        // Generate embedding for user message
        generateEmbeddingForMessage(userMessage)

        // Add to command history (in-memory and database)
        commandHistory.append(trimmedInput)
        historyIndex = commandHistory.count
        DatabaseService.shared.saveCommandHistory(input: trimmedInput)

        // Clear input
        inputText = ""

        // Generate real AI response
        generateAIResponse(to: cleanText, attachments: userMessage.attachments)
    }
    
    func cancelGeneration() {
        streamingTask?.cancel()
        streamingTask = nil
        isGenerating = false
        silentContinuePrompt = nil
    }

    func startAutoContinue() {
        silentContinuePrompt = "Please continue from where you left off."
        Task { @MainActor in
            generateAIResponse(to: "", attachments: nil)
        }
    }

    /// Clear the current chat — gated behind the unsaved-changes prompt if needed.
    func clearChat() {
        #if os(macOS)
        confirmDiscardIfNeeded(then: performClearChat)
        #else
        performClearChat()
        #endif
    }

    private func performClearChat() {
        messages.removeAll()
        sessionStats.reset()
        MCPService.shared.resetBashSessionApproval()
        showSystemMessage("Chat cleared")
    }

    /// Load a saved conversation — gated behind the unsaved-changes prompt if needed.
    func loadConversation(_ conversation: Conversation) {
        #if os(macOS)
        confirmDiscardIfNeeded(then: { [weak self] in self?.performLoadConversation(conversation) })
        #else
        performLoadConversation(conversation)
        #endif
    }

    private func performLoadConversation(_ conversation: Conversation) {
        do {
            guard let (loadedConversation, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
                showSystemMessage("Could not load conversation '\(conversation.name)'")
                return
            }

            messages.removeAll()
            sessionStats.reset()
            MCPService.shared.resetBashSessionApproval()
            messages = loadedMessages

            // Track identity so ⌘S can re-save under the same name
            currentConversationId = conversation.id
            currentConversationName = conversation.name
            savedMessageCount = loadedMessages.filter { $0.role != .system }.count
            notesEnabled = loadedConversation.notesEnabled
            notesFilename = loadedConversation.notesFilename

            // Rebuild session stats from loaded messages
            for msg in loadedMessages {
                sessionStats.addMessage(
                    inputTokens: msg.role == .user ? msg.tokens : nil,
                    outputTokens: msg.role == .assistant ? msg.tokens : nil,
                    cost: msg.cost
                )
            }

            showSystemMessage("Loaded conversation '\(conversation.name)'")

            // Auto-switch to the provider/model this conversation was created with
            if let modelId = conversation.primaryModel {
                Task { await switchToConversationModel(modelId) }
            }
        } catch {
            showSystemMessage("Failed to load: \(error.localizedDescription)")
        }
    }

    /// Infer which provider owns a given model ID based on naming conventions.
    /// Update the selected model and keep currentProvider + settings in sync.
    /// Call this whenever the user picks a model in the model selector.
    func selectModel(_ model: ModelInfo) {
        let newProvider = Self.inferProvider(from: model.id) ?? currentProvider
        selectedModel = model
        currentProvider = newProvider
        MCPService.shared.resetBashSessionApproval()
    }

    func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }

    nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
        // Apple Foundation Models
        if modelId.hasPrefix("apple-") { return .appleOnDevice }
        // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
        if modelId.contains("/") { return .openrouter }
        // Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
        if modelId.hasPrefix("claude-") { return .anthropic }
        // OpenAI direct
        if modelId.hasPrefix("gpt-") || modelId.hasPrefix("o1") || modelId.hasPrefix("o3")
            || modelId.hasPrefix("dall-e-") || modelId.hasPrefix("chatgpt-") { return .openai }
        // Ollama uses short local names with no vendor prefix
        return .ollama
    }

    /// Silently switch provider + model to match a loaded conversation.
    /// Shows a system message only on failure or on a successful switch.
    @MainActor
    private func switchToConversationModel(_ modelId: String) async {
        guard let targetProvider = Self.inferProvider(from: modelId) else {
            showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model")
            return
        }

        guard providerRegistry.hasValidAPIKey(for: targetProvider) else {
            showSystemMessage("⚠️ No API key for \(targetProvider.displayName) — keeping current model")
            return
        }

        // Switch provider if needed, or load models if not yet loaded
        if targetProvider != currentProvider || availableModels.isEmpty {
            if targetProvider != currentProvider {
                settings.defaultProvider = targetProvider
                currentProvider = targetProvider
                selectedModel = nil
                availableModels = []
                providerRegistry.clearCache()
            }

            isLoadingModels = true
            do {
                guard let provider = providerRegistry.getProvider(for: targetProvider) else {
                    isLoadingModels = false
                    showSystemMessage("⚠️ Could not connect to \(targetProvider.displayName) — keeping current model")
                    return
                }
                availableModels = try await provider.listModels()
                isLoadingModels = false
            } catch {
                isLoadingModels = false
                showSystemMessage("⚠️ Could not load \(targetProvider.displayName) models — keeping current model")
                return
            }
        }

        guard let model = availableModels.first(where: { $0.id == modelId }) else {
            showSystemMessage("⚠️ Model '\(modelId)' not available — keeping current model")
            return
        }

        guard selectedModel?.id != modelId else { return }  // already on it, no message needed

        selectedModel = model
        showSystemMessage("Switched to \(model.name) · \(targetProvider.displayName)")
    }
    
    func retryLastMessage() {
        guard let lastUserMessage = messages.last(where: { $0.role == .user }) else {
            showSystemMessage("No previous message to retry")
            return
        }

        // Remove last assistant response if exists
        if let lastMessage = messages.last, lastMessage.role == .assistant {
            messages.removeLast()
        }

        generateAIResponse(to: lastUserMessage.content, attachments: lastUserMessage.attachments)
    }

    func toggleMessageStar(messageId: UUID) {
        // Update in-memory state first (works for both saved and unsaved messages)
        if let index = messages.firstIndex(where: { $0.id == messageId }) {
            messages[index].isStarred.toggle()
            let newStarred = messages[index].isStarred
            // Persist to DB if the message exists there (saved conversations only)
            do {
                try DatabaseService.shared.setMessageStarred(messageId: messageId, starred: newStarred)
                Log.ui.info("Message \(messageId) starred: \(newStarred)")
            } catch {
                // FK error is expected for unsaved messages — in-memory state is already updated
                Log.ui.debug("Star not persisted for unsaved message \(messageId): \(error)")
            }
        }
    }

    // MARK: - Quick Save

    /// Called from the File menu — re-saves if already named, prompts for name + folder if not.
    func saveFromMenu() {
        #if os(macOS)
        attemptSaveCurrentConversation()
        #endif
    }

    /// Always prompts for a new name (and folder) and saves a fresh copy, switching the session to that copy.
    func saveAsFromMenu() {
        let chatMessages = messages.filter { $0.role != .system }
        guard !chatMessages.isEmpty else { return }
        #if os(macOS)
        guard let details = promptForConversationDetails(title: "Save Chat As", defaultName: currentConversationName ?? "") else { return }
        do {
            let saved = try DatabaseService.shared.saveConversation(
                id: UUID(), name: details.name, messages: chatMessages,
                primaryModel: selectedModel?.id, folderId: details.folderId
            )
            currentConversationId = saved.id
            currentConversationName = details.name
            savedMessageCount = chatMessages.count
            notesEnabled = false
            notesFilename = nil
            showSystemMessage("Saved as \"\(details.name)\"")
            Task { await GitSyncService.shared.autoSync() }
        } catch {
            showSystemMessage("Save failed: \(error.localizedDescription)")
        }
        #endif
    }

    #if os(macOS)
    /// Shows the "Save Chat" alert with a name field and folder picker (including inline
    /// "New Folder…" creation). Returns nil if the user cancels or leaves the name empty.
    private func promptForConversationDetails(title: String = "Save Chat", defaultName: String) -> (name: String, folderId: UUID?)? {
        let folders = (try? DatabaseService.shared.listFolders()) ?? []
        let accessory = ConversationSaveAccessory(defaultName: defaultName, folders: folders, selectedFolderId: nil)

        let alert = NSAlert()
        alert.messageText = title
        alert.informativeText = "Enter a name for this conversation:"
        alert.addButton(withTitle: "Save")
        alert.addButton(withTitle: "Cancel")
        alert.accessoryView = accessory.container
        alert.window.initialFirstResponder = accessory.nameField

        guard alert.runModal() == .alertFirstButtonReturn else { return nil }
        let name = accessory.nameField.stringValue.trimmingCharacters(in: .whitespaces)
        guard !name.isEmpty else { return nil }
        return (name, accessory.resolvedFolderId)
    }
    #endif

    /// Called by ConversationListView after renaming a saved conversation.
    /// Updates the in-session name if the renamed conversation is currently open.
    func didRenameConversation(id: UUID, newName: String) {
        if currentConversationId == id {
            currentConversationName = newName
        }
    }

    /// Re-save the current conversation under its existing name, or prompt if never saved.
    func quickSave() {
        let chatMessages = messages.filter { $0.role != .system }
        guard !chatMessages.isEmpty else { return }

        if let id = currentConversationId, let name = currentConversationName {
            // Update existing saved conversation
            do {
                try DatabaseService.shared.updateConversation(
                    id: id, name: name, messages: chatMessages,
                    primaryModel: selectedModel?.id
                )
                savedMessageCount = chatMessages.count
                showSystemMessage("Saved \"\(name)\"")
            } catch {
                showSystemMessage("Save failed: \(error.localizedDescription)")
            }
        } else {
            showSystemMessage("No name yet — use /save <name> to save this conversation")
        }
    }

    // MARK: - Command Handling

    private func handleCommand(_ command: String) {
        guard let (cmd, args) = command.parseCommand() else {
            showSystemMessage("Invalid command")
            return
        }
        
        switch cmd.lowercased() {
        case "/help":
            showHelp = true

        case "/history":
            showHistory = true

        case "/model":
            showModelSelector = true
            
        case "/clear":
            clearChat()
            
        case "/retry":
            retryLastMessage()
            
        case "/memory":
            if let arg = args.first?.lowercased() {
                memoryEnabled = arg == "on"
                showSystemMessage(memoryEnabled ? "Memory enabled" : "Memory disabled")
            } else {
                showSystemMessage("Usage: /memory on|off")
            }
            
        case "/online":
            if let arg = args.first?.lowercased() {
                onlineMode = arg == "on"
                showSystemMessage(onlineMode ? "Online mode enabled" : "Online mode disabled")
            } else {
                showSystemMessage("Usage: /online on|off")
            }
            
        case "/stats":
            showStats = true

        case "/config", "/settings":
            showSettings = true
            
        case "/provider":
            if let providerName = args.first?.lowercased() {
                if let provider = Settings.Provider.allCases.first(where: { $0.rawValue == providerName }) {
                    currentProvider = provider
                    showSystemMessage("Switched to \(provider.displayName) provider")
                } else {
                    showSystemMessage("Unknown provider: \(providerName)")
                }
            } else {
                showSystemMessage("Current provider: \(currentProvider.displayName)")
            }
            
        case "/save":
            if let name = args.first {
                let chatMessages = messages.filter { $0.role != .system }
                guard !chatMessages.isEmpty else {
                    showSystemMessage("Nothing to save — no messages in this conversation")
                    return
                }
                do {
                    let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
                    currentConversationId = saved.id
                    currentConversationName = name
                    savedMessageCount = chatMessages.count
                    notesEnabled = false
                    notesFilename = nil
                    showSystemMessage("Conversation saved as '\(name)'")
                } catch {
                    showSystemMessage("Failed to save: \(error.localizedDescription)")
                }
            } else {
                // Re-save without a name if already saved; otherwise prompt
                quickSave()
            }

        case "/load", "/list":
            showConversations = true

        case "/delete":
            if let name = args.first {
                do {
                    let deleted = try DatabaseService.shared.deleteConversation(name: name)
                    if deleted {
                        showSystemMessage("Deleted conversation '\(name)'")
                    } else {
                        showSystemMessage("No conversation found with name '\(name)'")
                    }
                } catch {
                    showSystemMessage("Failed to delete: \(error.localizedDescription)")
                }
            } else {
                showSystemMessage("Usage: /delete <name>")
            }
            
        case "/export":
            if args.count >= 1 {
                let format = args[0].lowercased()
                let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
                exportConversation(format: format, filename: filename)
            } else {
                showSystemMessage("Usage: /export md|html|pdf|json <filename>")
            }
            
        case "/info":
            if let modelId = args.first {
                if let model = availableModels.first(where: { $0.id == modelId || $0.name.lowercased() == modelId.lowercased() }) {
                    showModelInfo(model)
                } else {
                    showSystemMessage("Model not found: \(modelId)")
                }
            } else if let model = selectedModel {
                showModelInfo(model)
            } else {
                showSystemMessage("No model selected")
            }
            
        case "/credits":
            showCredits = true

        case "/shortcuts":
            showShortcuts = true

        case "/skills":
            showSkills = true

        case "/jarvis":
            showJarvis = true

        case "/mcp":
            handleMCPCommand(args: args)

        case "/notes":
            handleNotesCommand(args: args)

        default:
            // Check user-defined shortcuts
            if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) {
                let userInput = args.joined(separator: " ")
                let prompt = shortcut.needsInput
                    ? shortcut.template.replacingOccurrences(of: "{{input}}", with: userInput)
                    : shortcut.template
                let msg = Message(
                    role: .user,
                    content: prompt,
                    tokens: prompt.estimateTokens(),
                    cost: nil,
                    timestamp: Date(),
                    attachments: nil,
                    modelId: selectedModel?.id
                )
                messages.append(msg)
                sessionStats.addMessage(inputTokens: msg.tokens, outputTokens: nil, cost: nil)
                generateEmbeddingForMessage(msg)
                generateAIResponse(to: prompt, attachments: nil)
                return
            }
            showSystemMessage("Unknown command: \(cmd)\nType /help for available commands")
        }
    }
    
    // MARK: - AI Response Generation
    
    private func generateAIResponse(to prompt: String, attachments: [FileAttachment]?) {
        // Get provider
        guard let provider = providerRegistry.getCurrentProvider() else {
            Log.ui.warning("Cannot generate: no API key configured")
            showSystemMessage("❌ No API key configured. Please add your API key in Settings.")
            return
        }

        guard let modelId = selectedModel?.id else {
            Log.ui.warning("Cannot generate: no model selected")
            showSystemMessage("❌ No model selected. Please select a model first.")
            return
        }

        Log.ui.info("Sending message: model=\(modelId), messages=\(self.messages.count)")

        // Dispatch to tool-aware path when MCP is enabled with folders or Anytype is enabled
        // Skip for image generation models — they don't support tool calling
        let mcp = MCPService.shared
        let mcpActive = mcpEnabled || settings.mcpEnabled
        let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
        let bashActive = settings.bashEnabled
        let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
        let researchAgentsActive = settings.agentsEnabled
        let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
        // Dedicated images API path (OpenRouter /images endpoint — separate from chat completions)
        if selectedModel?.capabilities.usesImagesAPI == true,
           let orProvider = provider as? OpenRouterProvider {
            generateImageAPIResponse(orProvider: orProvider, modelId: modelId, prompt: prompt)
            return
        }

        let modelSupportTools = selectedModel?.capabilities.tools ?? false
        if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
            generateAIResponseWithTools(provider: provider, modelId: modelId)
            return
        }

        isGenerating = true
        
        // Cancel any existing task
        streamingTask?.cancel()
        
        // Start streaming
        streamingTask = Task {
            let startTime = Date()
            var messageId: UUID?
            do {
                // Create empty assistant message for streaming
                let assistantMessage = Message(
                    role: .assistant,
                    content: "",
                    tokens: nil,
                    cost: nil,
                    timestamp: Date(),
                    attachments: nil,
                    modelId: modelId,
                    isStreaming: true
                )
                messageId = assistantMessage.id

                // Already on MainActor
                messages.append(assistantMessage)
                
                // Build chat request AFTER adding the assistant message
                // Only include messages up to (but not including) the streaming assistant message
                var messagesToSend = Array(messages.dropLast()) // Remove the empty assistant message

                // Web search via our WebSearchService
                // Append results to last user message content (matching Python oAI approach)
                if onlineMode && currentProvider != .openrouter && !messagesToSend.isEmpty {
                    if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
                        Log.search.info("Running web search for \(currentProvider.displayName)")
                        let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
                        if !results.isEmpty {
                            let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
                            messagesToSend[lastUserIdx].content += searchContext
                            Log.search.info("Injected \(results.count) search results into user message")
                        }
                    }
                }

                let isImageGen = selectedModel?.capabilities.imageGeneration ?? false
                if isImageGen {
                    Log.ui.info("Image generation mode for model \(modelId)")
                }

                // Smart context selection
                let contextStrategy: SelectionStrategy
                if !memoryEnabled {
                    contextStrategy = .lastMessageOnly
                } else if settings.contextSelectionEnabled {
                    contextStrategy = .smart
                } else {
                    contextStrategy = .allMessages
                }

                let contextWindow = ContextSelectionService.shared.selectContext(
                    allMessages: messagesToSend,
                    strategy: contextStrategy,
                    maxTokens: selectedModel?.contextLength ?? settings.contextMaxTokens,
                    currentQuery: messagesToSend.last?.content
                )

                if contextWindow.excludedCount > 0 {
                    Log.ui.info("Smart context: selected \(contextWindow.messages.count) messages (\(contextWindow.totalTokens) tokens), excluded \(contextWindow.excludedCount)")
                }

                // Build system prompt with summaries (if any)
                var finalSystemPrompt = effectiveSystemPrompt
                if !contextWindow.summaries.isEmpty {
                    let summariesText = contextWindow.summaries.enumerated().map { index, summary in
                        "[Previous conversation summary (part \(index + 1)):]\\n\(summary)"
                    }.joined(separator: "\n\n")

                    finalSystemPrompt = summariesText + "\n\n---\n\n" + effectiveSystemPrompt
                }

                let reasoningConfig: ReasoningConfig? = {
                    guard settings.reasoningEnabled,
                          selectedModel?.capabilities.thinking == true,
                          !isImageGen else { return nil }
                    return ReasoningConfig(effort: settings.reasoningEffort, exclude: settings.reasoningExclude)
                }()

                let chatRequest = ChatRequest(
                    messages: contextWindow.messages,
                    model: modelId,
                    stream: settings.streamEnabled,
                    maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
                    temperature: settings.temperature > 0 ? settings.temperature : nil,
                    topP: nil,
                    systemPrompt: finalSystemPrompt,
                    tools: nil,
                    onlineMode: onlineMode,
                    imageGeneration: isImageGen,
                    reasoning: reasoningConfig
                )

                if isImageGen {
                    // Image generation: use non-streaming request
                    // Image models don't reliably support streaming
                    if let index = messages.firstIndex(where: { $0.id == messageId }) {
                        messages[index].content = ThinkingVerbs.random()
                    }

                    let nonStreamRequest = ChatRequest(
                        messages: chatRequest.messages,
                        model: chatRequest.model,
                        stream: false,
                        maxTokens: chatRequest.maxTokens,
                        temperature: chatRequest.temperature,
                        imageGeneration: true
                    )
                    let response = try await withOverloadedRetry { try await provider.chat(request: nonStreamRequest) }
                    let responseTime = Date().timeIntervalSince(startTime)

                    if let index = messages.firstIndex(where: { $0.id == messageId }) {
                        messages[index].content = applyNotesUpdateIfNeeded(response.content)
                        messages[index].isStreaming = false
                        messages[index].generatedImages = response.generatedImages
                        messages[index].responseTime = responseTime

                        if let usage = response.usage {
                            messages[index].tokens = usage.completionTokens
                            if let model = selectedModel {
                                let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
                                let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
                                messages[index].cost = cost
                                sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
                            }
                        }
                    }
                } else {
                    // Regular text: stream response
                    var fullContent = ""
                    var fullThinking = ""
                    var collectedImages: [Data] = []
                    var totalTokens: ChatResponse.Usage? = nil
                    var wasCancelled = false

                    for try await chunk in provider.streamChat(request: chatRequest) {
                        if Task.isCancelled {
                            wasCancelled = true
                            break
                        }

                        if let thinking = chunk.delta.thinking {
                            fullThinking += thinking
                            if let index = messages.firstIndex(where: { $0.id == messageId }) {
                                messages[index].thinkingContent = fullThinking
                            }
                        }

                        if let content = chunk.deltaContent {
                            fullContent += content
                            if let index = messages.firstIndex(where: { $0.id == messageId }) {
                                messages[index].content = fullContent
                            }
                        }

                        if let images = chunk.delta.images {
                            collectedImages.append(contentsOf: images)
                        }

                        if let usage = chunk.usage {
                            totalTokens = usage
                        }
                    }

                    // Check for cancellation one more time after loop exits
                    // (in case it was cancelled after the last chunk)
                    if Task.isCancelled {
                        wasCancelled = true
                    }

                    let responseTime = Date().timeIntervalSince(startTime)

                    if let index = messages.firstIndex(where: { $0.id == messageId }) {
                        messages[index].content = applyNotesUpdateIfNeeded(fullContent)
                        messages[index].isStreaming = false
                        messages[index].responseTime = responseTime
                        messages[index].wasInterrupted = wasCancelled
                        if !collectedImages.isEmpty {
                            messages[index].generatedImages = collectedImages
                        }

                        if let usage = totalTokens {
                            messages[index].tokens = usage.completionTokens
                            if let model = selectedModel {
                                let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
                                let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
                                messages[index].cost = cost
                                sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
                            }
                        }

                        // Generate embedding for assistant message
                        generateEmbeddingForMessage(messages[index])
                    }
                }

                isGenerating = false
                streamingTask = nil
                
            } catch {
                let responseTime = Date().timeIntervalSince(startTime)

                // Check if this was a cancellation (either by checking Task state or error type)
                let isCancellation = Task.isCancelled || error is CancellationError

                if isCancellation, let msgId = messageId {
                    // Mark the message as interrupted instead of removing it
                    if let index = messages.firstIndex(where: { $0.id == msgId }) {
                        messages[index].isStreaming = false
                        messages[index].wasInterrupted = true
                        messages[index].responseTime = responseTime
                    }
                } else if let msgId = messageId {
                    // For real errors, remove the empty streaming message
                    if let index = messages.firstIndex(where: { $0.id == msgId && $0.content.isEmpty }) {
                        messages.remove(at: index)
                    }
                    Log.api.error("Generation failed: \(error.localizedDescription)")
                    showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
                }

                isGenerating = false
                streamingTask = nil
            }
        }
    }

    // MARK: - File Attachment Reading

    private let maxFileSize: Int = 10 * 1024 * 1024  // 10 MB
    private let maxTextSize: Int = 50 * 1024          // 50 KB before truncation

    private func readFileAttachments(_ paths: [String]) -> [FileAttachment] {
        var attachments: [FileAttachment] = []
        let fm = FileManager.default

        for rawPath in paths {
            // Expand ~ and resolve path
            let expanded = (rawPath as NSString).expandingTildeInPath
            var resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded)

            // If not found, try iCloud Drive path
            if !fm.fileExists(atPath: resolvedPath) {
                let icloudBase = (("~/Library/Mobile Documents" as NSString).expandingTildeInPath as NSString)
                let candidate = icloudBase.appendingPathComponent(rawPath)
                if fm.fileExists(atPath: candidate) {
                    resolvedPath = candidate
                }
            }

            // Check file exists
            guard fm.fileExists(atPath: resolvedPath) else {
                showSystemMessage("⚠️ File not found: \(rawPath)")
                continue
            }

            // Check file size
            guard let attrs = try? fm.attributesOfItem(atPath: resolvedPath),
                  let fileSize = attrs[.size] as? Int else {
                showSystemMessage("⚠️ Cannot read file: \(rawPath)")
                continue
            }

            if fileSize > maxFileSize {
                let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
                showSystemMessage("⚠️ File too large (\(sizeMB) MB, max 10 MB): \(rawPath)")
                continue
            }

            let type = FileAttachment.typeFromExtension(resolvedPath)

            switch type {
            case .image, .pdf:
                // Read as raw data
                guard let data = fm.contents(atPath: resolvedPath) else {
                    showSystemMessage("⚠️ Could not read file: \(rawPath)")
                    continue
                }
                attachments.append(FileAttachment(path: rawPath, type: type, data: data))

            case .text:
                // Read as string
                guard let content = try? String(contentsOfFile: resolvedPath, encoding: .utf8) else {
                    showSystemMessage("⚠️ Could not read file as text: \(rawPath)")
                    continue
                }

                var finalContent = content
                // Truncate large text files
                if content.utf8.count > maxTextSize {
                    let lines = content.components(separatedBy: "\n")
                    if lines.count > 600 {
                        let head = lines.prefix(500).joined(separator: "\n")
                        let tail = lines.suffix(100).joined(separator: "\n")
                        let omitted = lines.count - 600
                        finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
                    }
                }

                attachments.append(FileAttachment(path: rawPath, type: .text, data: finalContent.data(using: .utf8)))
            }
        }

        return attachments
    }

    // MARK: - Text Tool Call Parsing

    /// Fallback parser for models that write tool calls as text instead of using structured tool_calls.
    /// Handles two patterns:
    ///   tool_name{"arg": "val"}     (no space between name and args)
    ///   tool_name({"arg": "val"})   (with wrapping parens)
    private func parseTextToolCalls(from content: String) -> [ToolCallInfo] {
        var results: [ToolCallInfo] = []

        // Match: word_chars optionally followed by ( then { ... } optionally followed by )
        // Use a broad pattern and validate JSON manually
        let pattern = #"([a-z_][a-z0-9_]*)\s*\(?\s*(\{[\s\S]*?\})\s*\)?"#
        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
            return []
        }

        let nsContent = content as NSString
        let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsContent.length))
        let knownTools = Set(MCPService.shared.getToolSchemas().map { $0.function.name })

        for match in matches {
            guard let nameRange = Range(match.range(at: 1), in: content),
                  let argsRange = Range(match.range(at: 2), in: content) else { continue }

            let name = String(content[nameRange])
            let argsStr = String(content[argsRange])

            // Only handle known tool names to avoid false positives
            guard knownTools.contains(name) else { continue }

            // Validate the JSON
            guard let _ = try? JSONSerialization.jsonObject(with: Data(argsStr.utf8)) else { continue }

            Log.ui.info("Parsed text tool call: \(name)")
            results.append(ToolCallInfo(id: UUID().uuidString, type: "function", functionName: name, arguments: argsStr))
        }

        return results
    }

    // MARK: - MCP Command Handling

    private func handleMCPCommand(args: [String]) {
        let mcp = MCPService.shared
        guard let sub = args.first?.lowercased() else {
            showSystemMessage("Usage: /mcp on|off|status|add|remove|list")
            return
        }

        switch sub {
        case "on":
            mcpEnabled = true
            settings.mcpEnabled = true
            mcpStatus = "MCP"
            showSystemMessage("MCP enabled (^[\(mcp.allowedFolders.count) folder](inflect: true) registered)")

        case "off":
            mcpEnabled = false
            settings.mcpEnabled = false
            mcpStatus = nil
            showSystemMessage("MCP disabled")

        case "add":
            if args.count >= 2 {
                let path = args.dropFirst().joined(separator: " ")
                if let error = mcp.addFolder(path) {
                    showSystemMessage("MCP: \(error)")
                } else {
                    showSystemMessage("MCP: Added folder — ^[\(mcp.allowedFolders.count) folder](inflect: true) registered")
                }
            } else {
                showSystemMessage("Usage: /mcp add <path>")
            }

        case "remove":
            if args.count >= 2 {
                let ref = args.dropFirst().joined(separator: " ")
                if let index = Int(ref) {
                    if mcp.removeFolder(at: index) {
                        showSystemMessage("MCP: Removed folder at index \(index)")
                    } else {
                        showSystemMessage("MCP: Invalid index \(index)")
                    }
                } else {
                    if mcp.removeFolder(path: ref) {
                        showSystemMessage("MCP: Removed folder")
                    } else {
                        showSystemMessage("MCP: Folder not found: \(ref)")
                    }
                }
            } else {
                showSystemMessage("Usage: /mcp remove <index|path>")
            }

        case "list":
            if mcp.allowedFolders.isEmpty {
                showSystemMessage("MCP: No folders registered. Use /mcp add <path>")
            } else {
                let list = mcp.allowedFolders.enumerated().map { "\($0): \($1)" }.joined(separator: "\n")
                showSystemMessage("MCP folders:\n\(list)")
            }

        case "write":
            guard args.count >= 2 else {
                showSystemMessage("Usage: /mcp write on|off")
                return
            }
            let toggle = args[1].lowercased()
            if toggle == "on" {
                settings.mcpCanWriteFiles = true
                settings.mcpCanDeleteFiles = true
                settings.mcpCanCreateDirectories = true
                settings.mcpCanMoveFiles = true
                showSystemMessage("MCP: All write permissions enabled (write, edit, delete, create dirs, move, copy)")
            } else if toggle == "off" {
                settings.mcpCanWriteFiles = false
                settings.mcpCanDeleteFiles = false
                settings.mcpCanCreateDirectories = false
                settings.mcpCanMoveFiles = false
                showSystemMessage("MCP: All write permissions disabled")
            } else {
                showSystemMessage("Usage: /mcp write on|off")
            }

        case "status":
            let enabled = mcpEnabled ? "enabled" : "disabled"
            let folders = mcp.allowedFolders.count
            var perms: [String] = []
            if settings.mcpCanWriteFiles { perms.append("write") }
            if settings.mcpCanDeleteFiles { perms.append("delete") }
            if settings.mcpCanCreateDirectories { perms.append("mkdir") }
            if settings.mcpCanMoveFiles { perms.append("move/copy") }
            let permStr = perms.isEmpty ? "read-only" : "read + \(perms.joined(separator: ", "))"
            showSystemMessage("MCP: \(enabled), ^[\(folders) folder](inflect: true), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")

        default:
            showSystemMessage("MCP subcommands: on, off, status, add, remove, list, write")
        }
    }

    // MARK: - Notes Command Handling

    private func handleNotesCommand(args: [String]) {
        guard let sub = args.first?.lowercased() else {
            showSystemMessage("Usage: /notes on|off|show")
            return
        }

        switch sub {
        case "on":
            guard let conversationId = currentConversationId else {
                showSystemMessage("Send a message first so this conversation is saved, then try /notes on")
                return
            }
            do {
                var filename = notesFilename
                if filename == nil {
                    let newFilename = ConversationNotesService.shared.makeFilename(
                        conversationName: currentConversationName ?? "Untitled",
                        conversationId: conversationId
                    )
                    ConversationNotesService.shared.write(body: "", filename: newFilename, conversationId: conversationId)
                    try DatabaseService.shared.setNotesFilename(id: conversationId, filename: newFilename)
                    filename = newFilename
                }
                try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: true)
                notesFilename = filename
                notesEnabled = true
                showSystemMessage("Notes enabled for this conversation")
            } catch {
                showSystemMessage("Failed to enable notes: \(error.localizedDescription)")
            }

        case "off":
            guard let conversationId = currentConversationId else {
                showSystemMessage("No active conversation")
                return
            }
            do {
                try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: false)
                notesEnabled = false
                showSystemMessage("Notes disabled for this conversation")
            } catch {
                showSystemMessage("Failed to disable notes: \(error.localizedDescription)")
            }

        case "show":
            guard notesEnabled, let filename = notesFilename,
                  let body = ConversationNotesService.shared.readBody(filename: filename),
                  !body.isEmpty
            else {
                showSystemMessage("No notes yet for this conversation")
                return
            }
            showSystemMessage("📝 Notes:\n\n\(body)")

        default:
            showSystemMessage("Usage: /notes on|off|show")
        }
    }

    // MARK: - AI Response with Tool Calls

    // MARK: - Images API Generation

    private func generateImageAPIResponse(orProvider: OpenRouterProvider, modelId: String, prompt: String) {
        isGenerating = true
        streamingTask?.cancel()
        streamingTask = Task {
            let startTime = Date()
            let assistantMessage = Message(
                role: .assistant,
                content: ThinkingVerbs.random(),
                tokens: nil,
                cost: nil,
                timestamp: Date(),
                attachments: nil,
                modelId: modelId,
                isStreaming: true
            )
            let messageId = assistantMessage.id
            messages.append(assistantMessage)

            do {
                let response = try await orProvider.generateImage(model: modelId, prompt: prompt)
                let responseTime = Date().timeIntervalSince(startTime)

                if let index = messages.firstIndex(where: { $0.id == messageId }) {
                    messages[index].content = response.content
                    messages[index].isStreaming = false
                    messages[index].generatedImages = response.generatedImages
                    messages[index].responseTime = responseTime

                    if let usage = response.usage {
                        messages[index].tokens = usage.completionTokens
                        let cost = usage.rawCostUSD
                        messages[index].cost = cost
                        sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
                    }
                }
            } catch {
                if let index = messages.firstIndex(where: { $0.id == messageId }) {
                    messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
                    messages[index].isStreaming = false
                }
                Log.api.error("Images API error: \(error)")
            }
            isGenerating = false
        }
    }

    private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
        let mcp = MCPService.shared
        Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
        isGenerating = true
        streamingTask?.cancel()

        streamingTask = Task {
            let startTime = Date()
            var wasCancelled = false
            // Accumulates ToolCallDetail entries across every round of this tool-calling loop —
            // collapsed into a single expandable summary message once the loop exits (success,
            // cancellation, or error), instead of one persisted message per round.
            var allToolCallDetails: [ToolCallDetail] = []
            do {
                // Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix)
                let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)

                // Apply :online suffix for OpenRouter when online mode is active
                var effectiveModelId = modelId
                if onlineMode && currentProvider == .openrouter && !modelId.hasSuffix(":online") {
                    effectiveModelId = modelId + ":online"
                }

                // Build initial messages as raw dictionaries for the tool loop
                var systemParts: [String] = []
                let mcpActive = mcpEnabled || settings.mcpEnabled

                if mcpActive && !mcp.allowedFolders.isEmpty {
                    let folderList = mcp.allowedFolders.joined(separator: "\n  - ")
                    var capabilities = "You can read files, list directories, and search for files."
                    var writeCapabilities: [String] = []
                    if mcp.canWriteFiles { writeCapabilities.append("write and edit files") }
                    if mcp.canDeleteFiles { writeCapabilities.append("delete files") }
                    if mcp.canCreateDirectories { writeCapabilities.append("create directories") }
                    if mcp.canMoveFiles { writeCapabilities.append("move and copy files") }
                    if !writeCapabilities.isEmpty {
                        capabilities += " You can also \(writeCapabilities.joined(separator: ", "))."
                    }
                    systemParts.append("You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n  - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths.")
                }

                if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
                    systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
                }

                let activeExternalServers = settings.externalMCPServers.filter { $0.isEnabled }
                if !activeExternalServers.isEmpty {
                    let names = activeExternalServers.map { $0.name }.joined(separator: ", ")
                    systemParts.append("You have access to external tools from MCP servers: \(names). Their tools are prefixed with the server slug (e.g. safari_navigate_to_url). Use them proactively when the user's request relates to what those servers provide.")
                }

                var systemContent = systemParts.joined(separator: "\n\n")

                // Append the complete system prompt (default + custom)
                systemContent += "\n\n---\n\n" + effectiveSystemPrompt

                let messagesToSend: [Message] = memoryEnabled
                    ? messages.filter { $0.role != .system }
                    : [messages.last(where: { $0.role == .user })].compactMap { $0 }

                let systemPrompt: [String: Any] = [
                    "role": "system",
                    "content": systemContent
                ]

                var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
                    let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
                    if hasAttachments {
                        var contentArray: [[String: Any]] = []
                        if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                            contentArray.append(["type": "text", "text": msg.content])
                        }
                        for attachment in msg.attachments ?? [] {
                            guard let data = attachment.data else { continue }
                            switch attachment.type {
                            case .image, .pdf:
                                let base64String = data.base64EncodedString()
                                let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
                                contentArray.append(["type": "image_url", "image_url": ["url": dataURL]])
                            case .text:
                                let filename = (attachment.path as NSString).lastPathComponent
                                let textContent = String(data: data, encoding: .utf8) ?? ""
                                contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
                            }
                        }
                        return ["role": msg.role.rawValue, "content": contentArray]
                    }
                    let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
                    return ["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content]
                }

                // If this is a silent auto-continue, inject the prompt into the API call only
                if let continuePrompt = silentContinuePrompt {
                    apiMessages.append(["role": "user", "content": continuePrompt])
                    silentContinuePrompt = nil
                }

                let maxIterations = 10  // Increased from 5 to reduce hitting client-side limit
                var finalContent = ""
                var finalImages: [Data] = []
                var didContinueAfterImages = false  // Only inject temp-file continuation once
                var totalUsage: ChatResponse.Usage?
                var hitIterationLimit = false  // Track if we exited due to hitting the limit
                var finishedWithEmptyContent = false  // Model stopped calling tools but said nothing

                for iteration in 0..<maxIterations {
                    if Task.isCancelled {
                        wasCancelled = true
                        break
                    }

                    let response = try await withOverloadedRetry {
                        try await provider.chatWithToolMessages(
                            model: effectiveModelId,
                            messages: apiMessages,
                            tools: tools,
                            maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
                            temperature: settings.temperature > 0 ? settings.temperature : nil
                        )
                    }

                    if let usage = response.usage { totalUsage = usage }

                    // Check if the model wants to call tools
                    // Also parse text-based tool calls for models that don't use structured tool_calls
                    let structuredCalls = response.toolCalls ?? []
                    let textCalls = structuredCalls.isEmpty ? parseTextToolCalls(from: response.content) : []
                    let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls

                    guard !toolCalls.isEmpty else {
                        // No tool calls — this is the final response
                        finalContent = response.content
                        if let images = response.generatedImages { finalImages = images }
                        Log.ui.debug("Tools final response: content='\(response.content.prefix(80))', images=\(response.generatedImages?.count ?? 0)")

                        // If images were generated and tools are available, save to temp files
                        // and continue the loop so the model can save them to the requested path.
                        if !finalImages.isEmpty && !didContinueAfterImages && iteration < maxIterations - 1 {
                            didContinueAfterImages = true
                            let timestamp = Int(Date().timeIntervalSince1970)
                            let tempPaths: [String] = finalImages.enumerated().compactMap { i, imgData in
                                let path = "/tmp/confab_generated_\(timestamp)_\(i).png"
                                let ok = FileManager.default.createFile(atPath: path, contents: imgData)
                                Log.ui.debug("Saved generated image to temp: \(path) ok=\(ok)")
                                return ok ? path : nil
                            }
                            if !tempPaths.isEmpty {
                                let pathList = tempPaths.joined(separator: ", ")
                                let assistantContent = response.content.isEmpty ? "[Image generated]" : response.content
                                apiMessages.append(["role": "assistant", "content": assistantContent])
                                apiMessages.append(["role": "user", "content": "The image(s) have been generated and temporarily saved to: \(pathList). Please save them to the requested destination(s) using the available tools (bash or MCP write)."])
                                finalImages = []
                                finalContent = ""
                                continue
                            }
                        }
                        if finalContent.isEmpty {
                            // Some models (observed with Qwen via OpenRouter) stop calling tools
                            // after a long tool-call chain but return no summarizing text at all.
                            // Silently nudge a follow-up turn instead of showing a placeholder bubble.
                            finishedWithEmptyContent = true
                        }
                        break
                    }

                    // Show what tools the model is calling as a transient status line rather than
                    // a new chat message — see currentToolActivity's doc comment.
                    let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
                    currentToolActivity = String(localized: "🔧 Calling: \(toolNames)")

                    // Initialise detail entries with inputs (results fill in below); appended to
                    // allToolCallDetails once this round finishes executing.
                    var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
                        ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
                    }

                    let usingTextCalls = !textCalls.isEmpty

                    if usingTextCalls {
                        // Text-based tool calls: keep assistant message as-is (the text content)
                        apiMessages.append(["role": "assistant", "content": response.content])
                    } else {
                        // Structured tool_calls: append assistant message with tool_calls field
                        var assistantMsg: [String: Any] = ["role": "assistant"]
                        if !response.content.isEmpty {
                            assistantMsg["content"] = response.content
                        }
                        let toolCallDicts: [[String: Any]] = toolCalls.map { tc in
                            [
                                "id": tc.id,
                                "type": tc.type,
                                "function": [
                                    "name": tc.functionName,
                                    "arguments": tc.arguments
                                ]
                            ]
                        }
                        assistantMsg["tool_calls"] = toolCallDicts
                        apiMessages.append(assistantMsg)
                    }

                    // Execute each tool and append results
                    var toolResultLines: [String] = []
                    for (i, tc) in toolCalls.enumerated() {
                        if Task.isCancelled {
                            wasCancelled = true
                            break
                        }

                        let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments, agentProvider: provider, agentModelId: effectiveModelId)
                        let resultJSON: String
                        if let data = try? JSONSerialization.data(withJSONObject: result),
                           let str = String(data: data, encoding: .utf8) {
                            // Cap tool results at 50 KB to avoid HTTP 413 on the next API call
                            let maxBytes = 50_000
                            if str.utf8.count > maxBytes {
                                let truncated = String(str.utf8.prefix(maxBytes))!
                                resultJSON = truncated + "\n... (result truncated, use a smaller limit or more specific query)"
                            } else {
                                resultJSON = str
                            }
                        } else {
                            resultJSON = "{\"error\": \"Failed to serialize result\"}"
                        }

                        // Record the result so the collapsed summary message can show it once
                        // the whole tool-calling loop finishes.
                        toolDetails[i].result = resultJSON

                        if usingTextCalls {
                            // Inject results as a user message for text-call models
                            toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
                        } else {
                            apiMessages.append([
                                "role": "tool",
                                "tool_call_id": tc.id,
                                "name": tc.functionName,
                                "content": resultJSON
                            ])
                        }
                    }

                    if usingTextCalls && !toolResultLines.isEmpty {
                        let combined = toolResultLines.joined(separator: "\n\n")
                        apiMessages.append(["role": "user", "content": combined])
                    }

                    allToolCallDetails.append(contentsOf: toolDetails)

                    // If this was the last iteration, note it
                    if iteration == maxIterations - 1 {
                        hitIterationLimit = true  // We're exiting with pending tool calls
                        finalContent = response.content
                    }
                }

                // Check for cancellation one more time after loop exits
                if Task.isCancelled {
                    wasCancelled = true
                }

                flushToolCallSummary(allToolCallDetails)

                // If we hit the iteration limit or the model returned no text at all, silently
                // nudge a follow-up turn instead of showing a placeholder/blank bubble.
                let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled

                // Display the final response as an assistant message
                let responseTime = Date().timeIntervalSince(startTime)
                if willAutoContinue && finalContent.isEmpty {
                    // Nothing worth showing yet — still record usage/cost for this turn.
                    if let usage = totalUsage, let model = selectedModel {
                        let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
                        let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
                        sessionStats.addMessage(
                            inputTokens: usage.promptTokens,
                            outputTokens: usage.completionTokens,
                            cost: cost
                        )
                    }
                } else {
                    let assistantMessage = Message(
                        role: .assistant,
                        content: applyNotesUpdateIfNeeded(finalContent),
                        tokens: totalUsage?.completionTokens,
                        cost: nil,
                        timestamp: Date(),
                        attachments: nil,
                        responseTime: responseTime,
                        wasInterrupted: wasCancelled,
                        modelId: modelId,
                        generatedImages: finalImages.isEmpty ? nil : finalImages
                    )
                    messages.append(assistantMessage)

                    // Calculate cost
                    if let usage = totalUsage, let model = selectedModel {
                        let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
                        let cost: Double? = hasPricing ? Self.calculateCost(usage: usage, pricing: model.pricing) : nil
                        if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
                            messages[index].cost = cost
                        }
                        sessionStats.addMessage(
                            inputTokens: usage.promptTokens,
                            outputTokens: usage.completionTokens,
                            cost: cost
                        )
                    }
                }

                isGenerating = false
                streamingTask = nil

                if willAutoContinue {
                    startAutoContinue()
                }

            } catch {
                let responseTime = Date().timeIntervalSince(startTime)

                // Same collapse as the success path — any tool rounds that completed before the
                // error/cancellation are still worth keeping a record of.
                flushToolCallSummary(allToolCallDetails)

                // Check if this was a cancellation
                let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError

                if isCancellation {
                    // Create an interrupted message
                    let assistantMessage = Message(
                        role: .assistant,
                        content: "",
                        timestamp: Date(),
                        responseTime: responseTime,
                        wasInterrupted: true,
                        modelId: modelId
                    )
                    messages.append(assistantMessage)
                } else {
                    Log.api.error("Tool generation failed: \(error.localizedDescription)")
                    showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
                }

                isGenerating = false
                streamingTask = nil
            }
        }
    }

    @discardableResult
    private func showSystemMessage(_ text: String.LocalizationValue) -> UUID {
        let message = Message(
            role: .system,
            content: String(localized: text),
            tokens: nil,
            cost: nil,
            timestamp: Date(),
            attachments: nil
        )
        messages.append(message)
        return message.id
    }

    private func updateToolCallMessage(id: UUID, details: [ToolCallDetail]) {
        if let idx = messages.firstIndex(where: { $0.id == id }) {
            messages[idx].toolCalls = details
        }
    }

    /// Clears the live tool-activity status line and, if any tool calls actually ran, collapses
    /// them into a single persisted, expandable summary message — used on every exit path of
    /// generateAIResponseWithTools's loop (success, cancellation, or error).
    private func flushToolCallSummary(_ details: [ToolCallDetail]) {
        currentToolActivity = nil
        guard !details.isEmpty else { return }
        let summaryId = showSystemMessage("🔧 Used ^[\(details.count) tool call](inflect: true)")
        updateToolCallMessage(id: summaryId, details: details)
    }

    // MARK: - Error Helpers

    private func friendlyErrorMessage(from error: Error) -> String {
        let desc = error.localizedDescription

        // Network connectivity
        if let urlError = error as? URLError {
            switch urlError.code {
            case .notConnectedToInternet, .networkConnectionLost:
                return "Unable to reach the server. Check your internet connection."
            case .timedOut:
                return "Request timed out. Try a shorter message or different model."
            case .cannotFindHost, .cannotConnectToHost:
                return "Cannot connect to the server. Check your network or provider URL."
            default:
                break
            }
        }

        // HTTP status codes in error messages
        if desc.contains("401") || desc.contains("403") || desc.lowercased().contains("unauthorized") || desc.lowercased().contains("invalid.*key") {
            return "Invalid API key. Update it in Settings (\u{2318},)."
        }
        if desc.contains("429") || desc.lowercased().contains("rate limit") {
            return "Rate limited. Wait a moment and try again."
        }
        if desc.contains("404") || desc.lowercased().contains("model not found") || desc.lowercased().contains("not available") {
            return "Model not available. Select a different model (\u{2318}M)."
        }
        if desc.contains("500") || desc.contains("502") || desc.contains("503") {
            return "Server error. The provider may be experiencing issues. Try again shortly."
        }

        // Overloaded / 529
        if desc.contains("529") || desc.lowercased().contains("overloaded") {
            return "API is overloaded. Please try again shortly."
        }

        // Timeout patterns
        if desc.lowercased().contains("timed out") || desc.lowercased().contains("timeout") {
            return "Request timed out. Try a shorter message or different model."
        }

        // Fallback
        return desc
    }

    /// Retry an async operation on overloaded (529) errors with exponential backoff.
    private func withOverloadedRetry<T>(maxAttempts: Int = 4, operation: () async throws -> T) async throws -> T {
        var attempt = 0
        while true {
            do {
                return try await operation()
            } catch {
                let desc = error.localizedDescription
                let isOverloaded = desc.contains("529") || desc.lowercased().contains("overloaded")
                attempt += 1
                if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
                    let delay = Double(1 << attempt) // 2s, 4s, 8s
                    Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
                    _ = await MainActor.run {
                        showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
                    }
                    try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                } else {
                    throw error
                }
            }
        }
    }

    // MARK: - Helpers

    private func showModelInfo(_ model: ModelInfo) {
        modelInfoTarget = model
    }

    func exportConversation(format: String, filename: String) {
        let chatMessages = messages.filter { $0.role != .system }
        guard !chatMessages.isEmpty else {
            showSystemMessage("Nothing to export — no messages")
            return
        }

        if format == "pdf" {
            let name = currentConversationName ?? "conversation"
            Task { @MainActor in
                do {
                    let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
                    if let url = ConversationExportService.writeToDownloads(data, filename: filename) {
                        showSystemMessage("Exported to \(url.path)")
                    } else {
                        showSystemMessage("Export failed: could not write file")
                    }
                } catch {
                    showSystemMessage("Export failed: \(error.localizedDescription)")
                }
            }
            return
        }

        let content: String
        switch format {
        case "md", "markdown":
            content = ConversationExportService.markdown(messages: chatMessages)
        case "html":
            content = ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
        case "json":
            let dicts = chatMessages.map { msg -> [String: String] in
                ["role": msg.role.rawValue, "content": msg.content]
            }
            if let data = try? JSONSerialization.data(withJSONObject: dicts, options: .prettyPrinted),
               let json = String(data: data, encoding: .utf8) {
                content = json
            } else {
                showSystemMessage("Failed to encode JSON")
                return
            }
        default:
            showSystemMessage("Unsupported format: \(format). Use md, html, pdf, or json.")
            return
        }

        if let url = ConversationExportService.writeToDownloads(content, filename: filename) {
            showSystemMessage("Exported to \(url.path)")
        } else {
            showSystemMessage("Export failed: could not write file")
        }
    }

    /// Same export formats as `exportConversation(format:filename:)`, but lets the user pick
    /// the name and location via a native save panel instead of always writing to Downloads.
    #if os(macOS)
    func exportConversationWithSavePanel(format: String, defaultFilename: String) {
        let chatMessages = messages.filter { $0.role != .system }
        guard !chatMessages.isEmpty else {
            showSystemMessage("Nothing to export — no messages")
            return
        }

        let panel = NSSavePanel()
        panel.nameFieldStringValue = defaultFilename
        panel.canCreateDirectories = true
        panel.allowedContentTypes = {
            switch format {
            case "html": return [.html]
            case "pdf": return [.pdf]
            default: return [UTType(filenameExtension: "md") ?? .plainText]
            }
        }()
        if let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
            panel.directoryURL = downloads
        }

        guard panel.runModal() == .OK, let url = panel.url else { return }

        if format == "pdf" {
            let name = currentConversationName ?? "conversation"
            Task { @MainActor in
                do {
                    let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
                    try data.write(to: url, options: .atomic)
                    showSystemMessage("Exported to \(url.path)")
                } catch {
                    showSystemMessage("Export failed: \(error.localizedDescription)")
                }
            }
            return
        }

        let content = format == "html"
            ? ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
            : ConversationExportService.markdown(messages: chatMessages)

        do {
            try content.write(to: url, atomically: true, encoding: .utf8)
            showSystemMessage("Exported to \(url.path)")
        } catch {
            showSystemMessage("Export failed: \(error.localizedDescription)")
        }
    }
    #endif

    // MARK: - Auto-Save & Background Summarization

    /// Summarize the current conversation in the background (hidden from user)
    /// Returns a 3-5 word title, or nil on failure
    func summarizeConversationInBackground() async -> String? {
        // Need at least a few messages to summarize
        let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
        guard chatMessages.count >= 2 else {
            return nil
        }

        // Get current provider
        guard let provider = providerRegistry.getCurrentProvider() else {
            Log.ui.warning("Cannot summarize: no provider configured")
            return nil
        }

        guard let modelId = selectedModel?.id else {
            Log.ui.warning("Cannot summarize: no model selected")
            return nil
        }

        Log.ui.info("Background summarization: model=\(modelId), messages=\(chatMessages.count)")

        do {
            // Simplified summarization prompt
            let summaryPrompt = "Create a brief 3-5 word title for this conversation. Just the title, nothing else."

            // Build chat request with just the last few messages for context
            let recentMessages = Array(chatMessages.suffix(10))  // Last 10 messages for context
            var summaryMessages = recentMessages.map { msg in
                Message(role: msg.role, content: msg.content, tokens: nil, cost: nil, timestamp: Date())
            }

            // Add the summary request as a user message
            summaryMessages.append(Message(
                role: .user,
                content: summaryPrompt,
                tokens: nil,
                cost: nil,
                timestamp: Date()
            ))

            let chatRequest = ChatRequest(
                messages: summaryMessages,
                model: modelId,
                stream: false, // Non-streaming for background request
                maxTokens: 100, // Increased for better response
                temperature: 0.3, // Lower for more focused response
                topP: nil,
                systemPrompt: "You are a helpful assistant that creates concise conversation titles.",
                tools: nil,
                onlineMode: false,
                imageGeneration: false
            )

            // Make the request (hidden from user)
            let response = try await provider.chat(request: chatRequest)

            Log.ui.info("Raw summary response: '\(response.content)'")

            // Extract and clean the summary
            var summary = response.content
                .trimmingCharacters(in: .whitespacesAndNewlines)
                .replacingOccurrences(of: "\"", with: "")
                .replacingOccurrences(of: "'", with: "")
                .replacingOccurrences(of: "Title:", with: "", options: .caseInsensitive)
                .replacingOccurrences(of: "title:", with: "")
                .trimmingCharacters(in: .whitespacesAndNewlines)

            // Take only first line if multi-line response
            if let firstLine = summary.components(separatedBy: .newlines).first {
                summary = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
            }

            // Limit length
            summary = String(summary.prefix(60))

            Log.ui.info("Cleaned summary: '\(summary)'")

            // Return nil if empty
            guard !summary.isEmpty else {
                Log.ui.warning("Summary is empty after cleaning")
                return nil
            }

            return summary

        } catch {
            Log.ui.error("Background summarization failed: \(error.localizedDescription)")
            return nil
        }
    }

    // MARK: - Crash-Recovery Draft

    /// Pure fingerprint of a message list's content, used to skip rewriting the crash-recovery
    /// draft to disk when nothing has actually changed. Not stable across process launches
    /// (String hashing is randomized per-run) — only ever compared within a single session.
    nonisolated static func draftFingerprint(for messages: [Message]) -> Int {
        messages.map { "\($0.role.rawValue)|\($0.content)" }.joined(separator: "\u{1}").hashValue
    }

    /// (Re)schedules the periodic draft-persistence timer using the current
    /// `settings.draftRecoveryIntervalSeconds`. Call again after Settings changes it.
    /// A value of 0 disables the periodic tick (and clears any existing draft).
    func startDraftTimer() {
        draftTimer?.invalidate()
        draftTimer = nil

        let interval = settings.draftRecoveryIntervalSeconds
        guard interval > 0 else {
            DraftRecoveryService.shared.clear()
            return
        }

        draftTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(interval), repeats: true) { [weak self] _ in
            Task { @MainActor [weak self] in
                self?.persistDraftIfChanged()
            }
        }
    }

    private func persistDraftIfChanged() {
        guard settings.draftRecoveryIntervalSeconds > 0 else { return }

        let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
        guard !chatMessages.isEmpty else { return }

        let fingerprint = Self.draftFingerprint(for: chatMessages)
        guard fingerprint != lastDraftFingerprint else { return }
        lastDraftFingerprint = fingerprint

        DraftRecoveryService.shared.save(DraftConversation(
            messages: chatMessages,
            conversationId: currentConversationId,
            conversationName: currentConversationName,
            modelId: selectedModel?.id,
            savedAt: Date()
        ))
    }

    /// Called once on launch from `ContentView.onAppear` in `ContentView.swift`, deferred one
    /// run-loop tick via `DispatchQueue.main.async` so the modal alert reliably presents — calling
    /// it directly and undeferred from `.onAppear` was tried and failed silently. Previously called
    /// from `AppDelegate.applicationDidFinishLaunching`, but that read a throwaway `ChatViewModel`
    /// instance from `oAIApp.init()`'s own `@State` rather than the one actually rendered (confirmed
    /// via ObjectIdentifier logging — restore appeared to work but never touched the visible chat).
    /// Offers to restore a conversation left behind by a crash or force-quit. A no-op after a clean
    /// shutdown, since `confirmDiscardIfNeeded` always clears the draft before New Chat/Clear/Switch/Quit proceed.
    func checkForCrashRecoveryDraft() {
        guard !hasCheckedForCrashRecoveryDraft else { return }
        hasCheckedForCrashRecoveryDraft = true

        // oAITests is app-hosted, so `xcodebuild test` launches this same app as the test host —
        // without this guard, a leftover draft file on disk (e.g. from manual kill-9 testing)
        // makes the test host hit this exact blocking NSAlert.runModal() with no one there to
        // click it, hanging the entire test run indefinitely.
        guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }

        let loaded = DraftRecoveryService.shared.load()
        Log.ui.info("checkForCrashRecoveryDraft: found draft = \(loaded != nil), messageCount = \(loaded?.messages.count ?? -1)")
        guard let draft = loaded, !draft.messages.isEmpty else { return }

        #if os(macOS)
        // Bring the app frontmost first — this runs one tick after launch, before the app has
        // necessarily activated, and an app-modal alert shown to a non-active app can end up
        // behind other windows (clicks land on whatever's actually frontmost, not the alert).
        NSApp.activate(ignoringOtherApps: true)

        let alert = NSAlert()
        alert.alertStyle = .informational
        alert.messageText = "Restore unsaved conversation?"
        alert.informativeText = "Confab didn't close properly last time. Would you like to restore the conversation you were working on?"
        alert.addButton(withTitle: "Restore")
        let discardButton = alert.addButton(withTitle: "Discard")
        discardButton.keyEquivalent = "d"
        discardButton.keyEquivalentModifierMask = .command

        if alert.runModal() == .alertFirstButtonReturn {
            messages = draft.messages
            currentConversationId = draft.conversationId
            currentConversationName = draft.conversationName
            savedMessageCount = 0 // always treat a restored draft as unsaved
            // Clear the on-disk draft now that it's loaded into memory — otherwise this exact
            // file lingers forever (a clean quit only clears it via confirmDiscardIfNeeded, which
            // never runs if the restored session is then quit before any new message is sent),
            // and the same restore prompt reappears on every future launch no matter what the
            // user picks here.
            DraftRecoveryService.shared.clear()
            showSystemMessage("Restored previous session (^[\(draft.messages.count) message](inflect: true))")
            if let modelId = draft.modelId {
                Task { await switchToConversationModel(modelId) }
            }
        } else {
            DraftRecoveryService.shared.clear()
        }
        #endif
    }

    // MARK: - Unsaved Changes Gate

    #if os(macOS)
    /// Standard macOS "unsaved changes" gate. If there are no unsaved changes, `proceed()` runs
    /// immediately. Otherwise shows a Save / Don't Save / Cancel alert: Save attempts an explicit
    /// save (prompting for name/folder if never named) and only proceeds on success; Don't Save
    /// discards the crash-recovery draft and proceeds; Cancel calls `onCancel()` — the caller's
    /// original action (new chat / clear / switch / quit) must not happen.
    func confirmDiscardIfNeeded(then proceed: @escaping () -> Void, onCancel: @escaping () -> Void = {}) {
        guard hasUnsavedChanges else { proceed(); return }

        let alert = NSAlert()
        alert.alertStyle = .warning
        alert.messageText = "Do you want to save the changes you made to \"\(currentConversationName ?? "Untitled Conversation")\"?"
        alert.informativeText = "Your changes will be lost if you don't save them."
        alert.addButton(withTitle: "Save")
        let dontSave = alert.addButton(withTitle: "Don't Save")
        dontSave.keyEquivalent = "d"
        dontSave.keyEquivalentModifierMask = .command
        alert.addButton(withTitle: "Cancel")

        switch alert.runModal() {
        case .alertFirstButtonReturn:
            if attemptSaveCurrentConversation() {
                DraftRecoveryService.shared.clear()
                proceed()
            } else {
                onCancel()
            }
        case .alertSecondButtonReturn:
            DraftRecoveryService.shared.clear()
            proceed()
        default:
            onCancel()
        }
    }

    /// Saves the current conversation: silently re-saves in place if already named, otherwise
    /// prompts for a name (and optional folder) via `promptForConversationDetails`. Returns true
    /// on success (or if there was nothing to save), false if cancelled or the save failed.
    @discardableResult
    private func attemptSaveCurrentConversation() -> Bool {
        let chatMessages = messages.filter { $0.role != .system }
        guard !chatMessages.isEmpty else { return true }

        if let id = currentConversationId, let name = currentConversationName {
            do {
                try DatabaseService.shared.updateConversation(
                    id: id, name: name, messages: chatMessages, primaryModel: selectedModel?.id
                )
                savedMessageCount = chatMessages.count
                showSystemMessage("Saved \"\(name)\"")
                Task { await GitSyncService.shared.autoSync() }
                return true
            } catch {
                showSystemMessage("Save failed: \(error.localizedDescription)")
                return false
            }
        }

        guard let details = promptForConversationDetails(defaultName: "") else { return false }
        do {
            let saved = try DatabaseService.shared.saveConversation(
                id: UUID(), name: details.name, messages: chatMessages,
                primaryModel: selectedModel?.id, folderId: details.folderId
            )
            currentConversationId = saved.id
            currentConversationName = details.name
            savedMessageCount = chatMessages.count
            notesEnabled = false
            notesFilename = nil
            showSystemMessage("Saved as \"\(details.name)\"")
            Task { await GitSyncService.shared.autoSync() }
            return true
        } catch {
            showSystemMessage("Save failed: \(error.localizedDescription)")
            return false
        }
    }
    #endif

    // MARK: - Embedding Generation

    /// Generate embedding for a single message (awaitable, no Task spawned).
    /// Call this directly when you want to sequence or rate-limit requests.
    private func embedMessage(_ message: Message, provider: EmbeddingProvider) async {
        guard message.content.count > 20 else { return }
        // Skip if already embedded
        if let _ = try? EmbeddingService.shared.getMessageEmbedding(messageId: message.id) { return }
        do {
            let embedding = try await EmbeddingService.shared.generateEmbedding(
                text: message.content,
                provider: provider
            )
            try EmbeddingService.shared.saveMessageEmbedding(
                messageId: message.id,
                embedding: embedding,
                model: provider.defaultModel
            )
            Log.api.info("Generated embedding for message \(message.id) using \(provider.displayName)")
        } catch {
            let errorString = String(describing: error)
            if errorString.contains("FOREIGN KEY constraint failed") {
                Log.api.debug("Message \(message.id) not in database yet - will embed later during save or batch operation")
            } else {
                Log.api.error("Failed to generate embedding for message \(message.id): \(error)")
            }
        }
    }

    /// Fire-and-forget wrapper — runs at background priority so it never blocks the chat.
    func generateEmbeddingForMessage(_ message: Message) {
        guard settings.embeddingsEnabled else { return }
        guard message.content.count > 20 else { return }

        Task(priority: .background) {
            guard let provider = EmbeddingService.shared.getSelectedProvider() else {
                Log.api.warning("No embedding providers available - skipping embedding generation")
                return
            }
            await embedMessage(message, provider: provider)
        }
    }

    /// Batch generate embeddings for all messages in all conversations
    func batchEmbedAllConversations() async {
        guard settings.embeddingsEnabled else {
            showSystemMessage("Embeddings are disabled. Enable them in Settings > Advanced.")
            return
        }

        // Check if we have an embedding provider available
        guard let provider = EmbeddingService.shared.getSelectedProvider() else {
            showSystemMessage("⚠️ No embedding provider available. Please configure an API key for OpenAI, OpenRouter, or Google.")
            return
        }

        showSystemMessage("Starting batch embedding generation using \(provider.displayName)...")

        let conversations = (try? DatabaseService.shared.listConversations()) ?? []
        var processedMessages = 0
        var skippedMessages = 0

        for conv in conversations {
            guard let (_, messages) = try? DatabaseService.shared.loadConversation(id: conv.id) else {
                continue
            }

            for message in messages {
                // Skip if already embedded
                if let _ = try? EmbeddingService.shared.getMessageEmbedding(messageId: message.id) {
                    skippedMessages += 1
                    continue
                }

                // Skip very short messages
                guard message.content.count > 20 else {
                    skippedMessages += 1
                    continue
                }

                do {
                    let embedding = try await EmbeddingService.shared.generateEmbedding(
                        text: message.content,
                        provider: provider
                    )
                    try EmbeddingService.shared.saveMessageEmbedding(
                        messageId: message.id,
                        embedding: embedding,
                        model: provider.defaultModel
                    )
                    processedMessages += 1

                    // Rate limit: 10 embeddings/sec
                    try? await Task.sleep(for: .milliseconds(100))
                } catch {
                    Log.api.error("Failed to generate embedding for message \(message.id): \(error)")
                }
            }

            // Generate conversation embedding
            do {
                try await EmbeddingService.shared.generateConversationEmbedding(conversationId: conv.id)
            } catch {
                Log.api.error("Failed to generate conversation embedding for \(conv.id): \(error)")
            }
        }

        showSystemMessage("Batch embedding complete: \(processedMessages) messages processed, \(skippedMessages) skipped")
        Log.ui.info("Batch embedding complete: \(processedMessages) messages, \(skippedMessages) skipped")
    }

    // MARK: - Progressive Summarization

    /// Check if conversation needs summarization and create summaries if needed
    func checkAndSummarizeOldMessages(conversationId: UUID) async {
        guard settings.progressiveSummarizationEnabled else { return }

        let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
        let threshold = settings.summarizationThreshold

        guard chatMessages.count > threshold else { return }

        // Calculate which chunk to summarize (messages 0 to threshold-20)
        let chunkEnd = threshold - 20
        guard chunkEnd > 30 else { return } // Need at least 30 messages to summarize

        // Check if already summarized
        if let hasSummary = try? DatabaseService.shared.hasSummaryForRange(
            conversationId: conversationId,
            startIndex: 0,
            endIndex: chunkEnd
        ), hasSummary {
            return // Already summarized
        }

        // Get messages to summarize
        let messagesToSummarize = Array(chatMessages.prefix(chunkEnd))

        Log.ui.info("Summarizing messages 0-\(chunkEnd) for conversation \(conversationId)")

        // Generate summary
        guard let summary = await summarizeMessageChunk(messagesToSummarize) else {
            Log.ui.error("Failed to generate summary for conversation \(conversationId)")
            return
        }

        // Save summary
        do {
            try DatabaseService.shared.saveConversationSummary(
                conversationId: conversationId,
                startIndex: 0,
                endIndex: chunkEnd,
                summary: summary,
                model: selectedModel?.id,
                tokenCount: summary.estimateTokens()
            )
            Log.ui.info("Saved summary for messages 0-\(chunkEnd)")
        } catch {
            Log.ui.error("Failed to save summary: \(error)")
        }
    }

    /// Cost for one response's usage, accounting for Anthropic-style prompt-cache
    /// pricing when present: cache writes cost 1.25x the base input rate, cache
    /// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder —
    /// it does not need cache tokens subtracted from it.
    nonisolated static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
        let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
        let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
        let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
        let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000
        return inputCost + cacheReadCost + cacheWriteCost + outputCost
    }

    /// Summarize a chunk of messages into a concise summary
    private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
        guard let provider = providerRegistry.getProvider(for: currentProvider),
              let modelId = selectedModel?.id else {
            return nil
        }

        // Combine messages into text
        let combinedText = messages.map { msg in
            let role = msg.role == .user ? "User" : "Assistant"
            return "[\(role)]: \(msg.content)"
        }.joined(separator: "\n\n")

        // Create summarization prompt
        let summaryPrompt = """
        Please create a concise 2-3 paragraph summary of the following conversation.
        Focus on the main topics discussed, key decisions made, and important information exchanged.
        Do not include unnecessary details or greetings.

        Conversation:
        \(combinedText)
        """

        let summaryMessage = Message(role: .user, content: summaryPrompt)

        let request = ChatRequest(
            messages: [summaryMessage],
            model: modelId,
            stream: false,
            maxTokens: 500,
            temperature: 0.3,
            topP: nil,
            systemPrompt: "You are a helpful assistant that creates concise, informative summaries of conversations.",
            tools: nil,
            onlineMode: false,
            imageGeneration: false
        )

        do {
            let response = try await provider.chat(request: request)
            return response.content
        } catch {
            Log.api.error("Summary generation failed: \(error)")
            return nil
        }
    }
}
