//
//  MCPService.swift
//  Confab
//
//  MCP (Model Context Protocol) service for filesystem tool execution
//
// 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 PDFKit

@Observable
class MCPService {
    static let shared = MCPService()

    private(set) var allowedFolders: [String] = []
    private let settings = SettingsService.shared
    private let fm = FileManager.default

    private let maxFileSize = 10 * 1024 * 1024    // 10 MB
    private let maxTextDisplay = 50 * 1024          // 50 KB before truncation
    private let maxDirItems = 1000
    private let maxSearchResults = 100

    private let skipPatterns: Set<String> = [
        ".git", "node_modules", ".DS_Store", "__pycache__",
        ".build", ".swiftpm", "Pods", ".Trash", ".Spotlight-V100"
    ]

    /// Cached gitignore rules per allowed folder
    private var gitignoreRules: [String: GitignoreParser] = [:]

    private init() {
        allowedFolders = settings.mcpAllowedFolders
        if settings.mcpRespectGitignore {
            loadGitignores()
        }
    }

    // MARK: - Folder Management

    func addFolder(_ rawPath: String) -> String? {
        let expanded = (rawPath as NSString).expandingTildeInPath
        let resolved = (expanded as NSString).standardizingPath

        var isDir: ObjCBool = false
        guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
            return "Path is not a directory: \(rawPath)"
        }

        if allowedFolders.contains(resolved) {
            return "Folder already added: \(resolved)"
        }

        allowedFolders.append(resolved)
        settings.mcpAllowedFolders = allowedFolders
        if settings.mcpRespectGitignore {
            loadGitignoreForFolder(resolved)
        }
        return nil
    }

    func removeFolder(at index: Int) -> Bool {
        guard index >= 0 && index < allowedFolders.count else { return false }
        let removed = allowedFolders.remove(at: index)
        settings.mcpAllowedFolders = allowedFolders
        gitignoreRules.removeValue(forKey: removed)
        return true
    }

    func removeFolder(path: String) -> Bool {
        let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
        if let index = allowedFolders.firstIndex(of: resolved) {
            allowedFolders.remove(at: index)
            settings.mcpAllowedFolders = allowedFolders
            gitignoreRules.removeValue(forKey: resolved)
            return true
        }
        return false
    }

    func isPathAllowed(_ path: String) -> Bool {
        let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
        // Always allow the system temp directory — external MCP servers and tools write
        // intermediate data there (e.g. Safari MCP page-content files, generated images).
        // Check both NSTemporaryDirectory() (per-user Darwin temp dir) and /tmp (what this
        // codebase's own temp files actually use, e.g. ChatViewModel's /tmp/confab_generated_*
        // and ExternalMCPClient's /tmp/oai_mcp_* — they resolve to different directories.
        let tmpCandidates = [
            (NSTemporaryDirectory() as NSString).standardizingPath,
            "/tmp",
            "/private/tmp"
        ]
        if tmpCandidates.contains(where: { resolved.hasPrefix($0) }) { return true }
        return allowedFolders.contains { resolved.hasPrefix($0) }
    }

    // MARK: - Permission Helpers

    var canWriteFiles: Bool { settings.mcpCanWriteFiles }
    var canDeleteFiles: Bool { settings.mcpCanDeleteFiles }
    var canCreateDirectories: Bool { settings.mcpCanCreateDirectories }
    var canMoveFiles: Bool { settings.mcpCanMoveFiles }
    var respectGitignore: Bool { settings.mcpRespectGitignore }

    private let anytypeService = AnytypeMCPService.shared
    private let paperlessService = PaperlessService.shared
    private let eventKitService = EventKitService.shared
    private let contactsService = ContactsService.shared
    private let locationMapsService = LocationMapsService.shared

    // MARK: - Bash Approval State

    struct PendingBashCommand: Identifiable {
        let id = UUID()
        let command: String
        let workingDirectory: String
    }

    private(set) var pendingBashCommand: PendingBashCommand? = nil
    private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
    private(set) var bashSessionApproved: Bool = false

    // MARK: - Personal Data (Calendar/Reminders) Approval State

    struct PendingPersonalDataAction: Identifiable {
        let id = UUID()
        let toolName: String
        let argumentsJSON: String
        let summary: String
    }

    private(set) var pendingPersonalDataAction: PendingPersonalDataAction? = nil
    private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
    private(set) var personalDataSessionApproved: Bool = false

    // MARK: - Tool Schema Generation

    func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
        var tools: [Tool] = [
            makeTool(
                name: "read_file",
                description: "Read the contents of a file. Returns the text content of the file. PDFs are supported (text layer is extracted automatically) — scanned/image-only PDFs with no text layer will return an error. Maximum file size is 10MB.",
                properties: [
                    "file_path": prop("string", "The absolute path to the file to read")
                ],
                required: ["file_path"]
            ),
            makeTool(
                name: "list_directory",
                description: "List the contents of a directory. Returns file and directory names. Skips hidden/build directories like .git, node_modules, etc.",
                properties: [
                    "dir_path": prop("string", "The absolute path to the directory to list"),
                    "recursive": prop("boolean", "Whether to list recursively (default: false)")
                ],
                required: ["dir_path"]
            ),
            makeTool(
                name: "search_files",
                description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents (PDF text layers are searched too).",
                properties: [
                    "pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
                    "search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
                    "content_search": prop("string", "Optional text to search for inside files")
                ],
                required: ["pattern"]
            )
        ]

        if canWriteFiles {
            tools.append(makeTool(
                name: "write_file",
                description: "Create or overwrite a file with the given content. Parent directories are created automatically. WARNING: For existing files larger than ~200 lines, use edit_file instead — writing very large files in a single call may exceed output token limits and fail.",
                properties: [
                    "file_path": prop("string", "The absolute path to the file to write"),
                    "content": prop("string", "The text content to write to the file")
                ],
                required: ["file_path", "content"]
            ))
            tools.append(makeTool(
                name: "edit_file",
                description: "Find and replace text in a file. The old_text must appear exactly once in the file.",
                properties: [
                    "file_path": prop("string", "The absolute path to the file to edit"),
                    "old_text": prop("string", "The exact text to find (must be a unique match)"),
                    "new_text": prop("string", "The replacement text")
                ],
                required: ["file_path", "old_text", "new_text"]
            ))
        }

        if canDeleteFiles {
            tools.append(makeTool(
                name: "delete_file",
                description: "Delete a file at the given path.",
                properties: [
                    "file_path": prop("string", "The absolute path to the file to delete")
                ],
                required: ["file_path"]
            ))
        }

        if canCreateDirectories {
            tools.append(makeTool(
                name: "create_directory",
                description: "Create a directory (and any intermediate directories) at the given path.",
                properties: [
                    "dir_path": prop("string", "The absolute path to the directory to create")
                ],
                required: ["dir_path"]
            ))
        }

        if canMoveFiles {
            tools.append(makeTool(
                name: "move_file",
                description: "Move or rename a file or directory.",
                properties: [
                    "source": prop("string", "The absolute path of the file/directory to move"),
                    "destination": prop("string", "The absolute destination path")
                ],
                required: ["source", "destination"]
            ))
            tools.append(makeTool(
                name: "copy_file",
                description: "Copy a file or directory to a new location.",
                properties: [
                    "source": prop("string", "The absolute path of the file/directory to copy"),
                    "destination": prop("string", "The absolute destination path for the copy")
                ],
                required: ["source", "destination"]
            ))
        }

        // Add Anytype tools if enabled and configured
        if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
            tools.append(contentsOf: anytypeService.getToolSchemas())
        }

        // Add Paperless-NGX tools if enabled and configured
        if settings.paperlessEnabled && settings.paperlessConfigured {
            tools.append(contentsOf: paperlessService.getToolSchemas())
        }

        // Add Calendar/Reminders tools if enabled
        if settings.calendarEnabled || settings.remindersEnabled {
            tools.append(contentsOf: eventKitService.getToolSchemas(
                calendarEnabled: settings.calendarEnabled,
                remindersEnabled: settings.remindersEnabled
            ))
        }

        // Add Contacts tools if enabled
        if settings.contactsEnabled {
            tools.append(contentsOf: contactsService.getToolSchemas())
        }

        // Add Location/Maps tools if enabled
        if settings.locationMapsEnabled {
            tools.append(contentsOf: locationMapsService.getToolSchemas())
        }

        // Add bash_execute tool when bash is enabled
        if settings.bashEnabled {
            let workDir = settings.bashWorkingDirectory
            let timeout = settings.bashTimeout
            let approvalNote = settings.bashRequireApproval ? " User approval required before execution." : ""
            tools.append(makeTool(
                name: "bash_execute",
                description: "Execute a shell command via /bin/zsh and return stdout/stderr. Working directory: \(workDir). Timeout: \(timeout)s.\(approvalNote)",
                properties: [
                    "command": prop("string", "The shell command to execute")
                ],
                required: ["command"]
            ))
        }

        // Add web_search tool when online mode is active
        // (OpenRouter handles search natively via :online model suffix, so excluded here)
        if onlineMode {
            tools.append(makeTool(
                name: "web_search",
                description: "Search the web for current information using DuckDuckGo. Use this when you need up-to-date information, news, or facts not in your training data. Formulate a concise, focused search query.",
                properties: [
                    "query": prop("string", "The search query to look up")
                ],
                required: ["query"]
            ))
        }

        // Add spawn_research_agents when Research Agents are enabled
        if settings.agentsEnabled {
            tools.append(makeTool(
                name: "spawn_research_agents",
                description: "Spawn multiple READ-ONLY research sub-agents that investigate independent questions IN PARALLEL. Each sub-agent gets its own read_file/list_directory/search_files/web_search loop — no write, no bash, no nesting (sub-agents cannot call this tool). ONLY use this when you have 2 or more genuinely independent research questions that benefit from running at the same time (e.g. comparing several unrelated files, topics, or sources). Do NOT use this for a single lookup, a sequential task, or anything answerable with one direct tool call — call read_file/search_files/web_search yourself instead. Each sub-agent is its own full chain of model calls and meaningfully increases cost and latency; using this for trivial tasks is wasteful. Prefer the smallest number of tasks that actually need to run in parallel.",
                properties: [
                    "tasks": Tool.Function.Parameters.Property(
                        type: "array",
                        description: "List of independent, self-contained research questions — one per sub-agent. Keep this list as short as the task genuinely requires.",
                        items: .init(type: "string")
                    )
                ],
                required: ["tasks"]
            ))
        }

        // Add tools from external MCP servers (stdio JSON-RPC protocol)
        tools.append(contentsOf: ExternalMCPManager.shared.getToolSchemas())

        return tools
    }

    private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
        Tool(
            type: "function",
            function: Tool.Function(
                name: name,
                description: description,
                parameters: Tool.Function.Parameters(
                    type: "object",
                    properties: properties,
                    required: required
                )
            )
        )
    }

    private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
        Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
    }

    // MARK: - Tool Execution

    /// `agentProvider`/`agentModelId` are only needed for `spawn_research_agents`, which drives
    /// its own model calls — every other tool ignores them. Passed in by the caller's active
    /// chat session rather than stored on MCPService, since this service has no provider state.
    func executeTool(name: String, arguments: String, agentProvider: AIProvider? = nil, agentModelId: String? = nil) async -> [String: Any] {
        Log.mcp.info("Executing tool: \(name)")
        guard let argData = arguments.data(using: .utf8),
              let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
            Log.mcp.error("Invalid arguments JSON for tool \(name)")
            return ["error": "Invalid arguments JSON"]
        }

        switch name {
        case "read_file":
            guard let filePath = args["file_path"] as? String else {
                return ["error": "Missing required parameter: file_path"]
            }
            return readFile(filePath: filePath)

        case "list_directory":
            guard let dirPath = args["dir_path"] as? String else {
                return ["error": "Missing required parameter: dir_path"]
            }
            let recursive = args["recursive"] as? Bool ?? false
            return listDirectory(dirPath: dirPath, recursive: recursive)

        case "search_files":
            guard let pattern = args["pattern"] as? String else {
                return ["error": "Missing required parameter: pattern"]
            }
            let searchPath = args["search_path"] as? String
            let contentSearch = args["content_search"] as? String
            return searchFiles(pattern: pattern, searchPath: searchPath, contentSearch: contentSearch)

        case "write_file":
            guard canWriteFiles else {
                return ["error": "Permission denied: write_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
            }
            guard let filePath = args["file_path"] as? String,
                  let content = args["content"] as? String else {
                return ["error": "Missing required parameters: file_path, content"]
            }
            return writeFile(filePath: filePath, content: content)

        case "edit_file":
            guard canWriteFiles else {
                return ["error": "Permission denied: edit_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
            }
            guard let filePath = args["file_path"] as? String,
                  let oldText = args["old_text"] as? String,
                  let newText = args["new_text"] as? String else {
                return ["error": "Missing required parameters: file_path, old_text, new_text"]
            }
            return editFile(filePath: filePath, oldText: oldText, newText: newText)

        case "delete_file":
            guard canDeleteFiles else {
                return ["error": "Permission denied: delete_file is not enabled. Enable 'Delete Files' in Settings > MCP."]
            }
            guard let filePath = args["file_path"] as? String else {
                return ["error": "Missing required parameter: file_path"]
            }
            return deleteFile(filePath: filePath)

        case "create_directory":
            guard canCreateDirectories else {
                return ["error": "Permission denied: create_directory is not enabled. Enable 'Create Directories' in Settings > MCP."]
            }
            guard let dirPath = args["dir_path"] as? String else {
                return ["error": "Missing required parameter: dir_path"]
            }
            return createDirectory(dirPath: dirPath)

        case "move_file":
            guard canMoveFiles else {
                return ["error": "Permission denied: move_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
            }
            guard let source = args["source"] as? String,
                  let destination = args["destination"] as? String else {
                return ["error": "Missing required parameters: source, destination"]
            }
            return moveFile(source: source, destination: destination)

        case "copy_file":
            guard canMoveFiles else {
                return ["error": "Permission denied: copy_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
            }
            guard let source = args["source"] as? String,
                  let destination = args["destination"] as? String else {
                return ["error": "Missing required parameters: source, destination"]
            }
            return copyFile(source: source, destination: destination)

        case "bash_execute":
            guard settings.bashEnabled else {
                return ["error": "Bash execution is disabled. Enable it in Settings > MCP."]
            }
            guard let command = args["command"] as? String, !command.isEmpty else {
                return ["error": "Missing required parameter: command"]
            }
            let workDir = settings.bashWorkingDirectory
            if settings.bashRequireApproval {
                return await executeBashWithApproval(command: command, workingDirectory: workDir)
            } else {
                return await runBashCommand(command, workingDirectory: workDir)
            }

        case "web_search":
            let query = args["query"] as? String ?? ""
            guard !query.isEmpty else {
                return ["error": "Missing required parameter: query"]
            }
            let results = await WebSearchService.shared.search(query: query)
            if results.isEmpty {
                return ["results": [], "message": "No results found for: \(query)"]
            }
            let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
            return ["results": mapped]

        case "spawn_research_agents":
            guard settings.agentsEnabled else {
                return ["error": "Research agents are disabled. Enable 'Research Agents' in Settings > MCP."]
            }
            guard let agentProvider, let agentModelId else {
                return ["error": "Internal error: missing model context for spawn_research_agents"]
            }
            guard let tasks = args["tasks"] as? [String], !tasks.isEmpty else {
                return ["error": "Missing required parameter: tasks (non-empty array of strings)"]
            }
            return await runResearchAgents(tasks: tasks, provider: agentProvider, modelId: agentModelId)

        case "calendar_create_event", "reminders_create", "reminders_complete":
            guard settings.calendarEnabled || settings.remindersEnabled else {
                return ["error": "Calendar/Reminders access is disabled. Enable it in Settings > MCP."]
            }
            let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
            return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)

        default:
            // Route to external MCP servers (stdio JSON-RPC)
            if ExternalMCPManager.shared.isExternalTool(name) {
                return await ExternalMCPManager.shared.executeTool(name: name, argumentsJSON: arguments)
            }
            // Route anytype_* tools to AnytypeMCPService
            if name.hasPrefix("anytype_") {
                return await anytypeService.executeTool(name: name, arguments: arguments)
            }
            // Route paperless_* tools to PaperlessService
            if name.hasPrefix("paperless_") {
                return await paperlessService.executeTool(name: name, arguments: arguments)
            }
            // Route calendar_*/reminders_* read tools to EventKitService
            if name.hasPrefix("calendar_") || name.hasPrefix("reminders_") {
                return await eventKitService.executeTool(name: name, arguments: arguments)
            }
            // Route contacts_* tools to ContactsService
            if name.hasPrefix("contacts_") {
                return await contactsService.executeTool(name: name, arguments: arguments)
            }
            // Route location_*/maps_* tools to LocationMapsService
            if name.hasPrefix("location_") || name.hasPrefix("maps_") {
                return await locationMapsService.executeTool(name: name, arguments: arguments)
            }
            return ["error": "Unknown tool: \(name)"]
        }
    }

    // MARK: - Read Tool Implementations

    private func readFile(filePath: String) -> [String: Any] {
        let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolved) else {
            Log.mcp.warning("Read denied: path outside allowed folders: \(resolved)")
            return ["error": "Access denied: path is outside allowed folders"]
        }

        guard fm.fileExists(atPath: resolved) else {
            return ["error": "File not found: \(filePath)"]
        }

        guard let attrs = try? fm.attributesOfItem(atPath: resolved),
              let fileSize = attrs[.size] as? Int else {
            return ["error": "Cannot read file attributes: \(filePath)"]
        }

        if fileSize > maxFileSize {
            let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
            return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
        }

        let content: String
        if (resolved as NSString).pathExtension.lowercased() == "pdf" {
            guard let extracted = extractPDFText(atPath: resolved) else {
                return ["error": "Could not extract text from PDF (it may be scanned/image-only with no text layer, encrypted, or corrupted): \(filePath)"]
            }
            content = extracted
        } else {
            guard let text = try? String(contentsOfFile: resolved, encoding: .utf8) else {
                return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
            }
            content = text
        }

        var finalContent = content
        if content.utf8.count > maxTextDisplay {
            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
            }
        }

        return ["content": finalContent, "path": resolved, "size": fileSize]
    }

    /// Extracts plain text from a PDF's text layer, page by page. Returns nil for
    /// scanned/image-only PDFs (no text layer), encrypted, or otherwise unreadable files.
    func extractPDFText(atPath path: String) -> String? {
        guard let document = PDFDocument(url: URL(fileURLWithPath: path)) else { return nil }
        var text = ""
        for i in 0..<document.pageCount {
            guard let page = document.page(at: i), let pageText = page.string else { continue }
            text += pageText + "\n\n"
        }
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : text
    }

    private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] {
        let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolved) else {
            return ["error": "Access denied: path is outside allowed folders"]
        }

        var isDir: ObjCBool = false
        guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
            return ["error": "Directory not found: \(dirPath)"]
        }

        var items: [String] = []

        if recursive {
            guard let enumerator = fm.enumerator(atPath: resolved) else {
                return ["error": "Cannot enumerate directory"]
            }
            while let item = enumerator.nextObject() as? String {
                let components = item.components(separatedBy: "/")
                if components.contains(where: { skipPatterns.contains($0) }) {
                    enumerator.skipDescendants()
                    continue
                }
                let fullPath = (resolved as NSString).appendingPathComponent(item)
                if respectGitignore && isGitignored(fullPath) {
                    // Skip gitignored directories entirely
                    var itemIsDir: ObjCBool = false
                    if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
                        enumerator.skipDescendants()
                    }
                    continue
                }
                items.append(item)
                if items.count >= maxDirItems { break }
            }
        } else {
            guard let contents = try? fm.contentsOfDirectory(atPath: resolved) else {
                return ["error": "Cannot list directory"]
            }
            items = contents.filter { !skipPatterns.contains($0) }
                .filter { entry in
                    if respectGitignore {
                        let fullPath = (resolved as NSString).appendingPathComponent(entry)
                        return !isGitignored(fullPath)
                    }
                    return true
                }
                .prefix(maxDirItems).map { entry in
                    var entryIsDir: ObjCBool = false
                    let fullPath = (resolved as NSString).appendingPathComponent(entry)
                    fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir)
                    return entryIsDir.boolValue ? "\(entry)/" : entry
                }
        }

        let truncated = items.count >= maxDirItems
        return ["items": items, "count": items.count, "truncated": truncated, "path": resolved]
    }

    private func searchFiles(pattern: String, searchPath: String?, contentSearch: String?) -> [String: Any] {
        let basePath: String
        if let sp = searchPath {
            basePath = ((sp as NSString).expandingTildeInPath as NSString).standardizingPath
        } else if let first = allowedFolders.first {
            basePath = first
        } else {
            return ["error": "No search path specified and no allowed folders configured"]
        }

        guard isPathAllowed(basePath) else {
            return ["error": "Access denied: search path is outside allowed folders"]
        }

        guard let enumerator = fm.enumerator(atPath: basePath) else {
            return ["error": "Cannot enumerate directory: \(basePath)"]
        }

        var results: [String] = []

        while let item = enumerator.nextObject() as? String {
            let components = item.components(separatedBy: "/")
            if components.contains(where: { skipPatterns.contains($0) }) {
                enumerator.skipDescendants()
                continue
            }

            let fullPath = (basePath as NSString).appendingPathComponent(item)

            if respectGitignore && isGitignored(fullPath) {
                var itemIsDir: ObjCBool = false
                if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
                    enumerator.skipDescendants()
                }
                continue
            }

            let filename = (item as NSString).lastPathComponent

            // Filename pattern match
            if fnmatch(pattern, filename, 0) != 0 {
                continue
            }

            // Content search if requested
            if let searchText = contentSearch, !searchText.isEmpty {
                let content: String?
                if (fullPath as NSString).pathExtension.lowercased() == "pdf" {
                    content = extractPDFText(atPath: fullPath)
                } else {
                    content = try? String(contentsOfFile: fullPath, encoding: .utf8)
                }
                guard let content, content.localizedCaseInsensitiveContains(searchText) else {
                    continue
                }
            }

            results.append(item)
            if results.count >= maxSearchResults { break }
        }

        let truncated = results.count >= maxSearchResults
        return ["matches": results, "count": results.count, "truncated": truncated, "base_path": basePath]
    }

    // MARK: - Write Tool Implementations

    private func writeFile(filePath: String, content: String) -> [String: Any] {
        let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolved) else {
            Log.mcp.warning("Write denied: path outside allowed folders: \(resolved)")
            return ["error": "Access denied: path is outside allowed folders"]
        }

        // Create parent directories if needed
        let parentDir = (resolved as NSString).deletingLastPathComponent
        if !fm.fileExists(atPath: parentDir) {
            do {
                try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
            } catch {
                return ["error": "Cannot create parent directories: \(error.localizedDescription)"]
            }
        }

        do {
            try content.write(toFile: resolved, atomically: true, encoding: .utf8)
        } catch {
            Log.mcp.error("Failed to write file \(resolved): \(error.localizedDescription)")
            return ["error": "Cannot write file: \(error.localizedDescription)"]
        }

        Log.mcp.info("Wrote \(content.utf8.count) bytes to \(resolved)")
        return ["success": true, "path": resolved, "bytes_written": content.utf8.count]
    }

    private func editFile(filePath: String, oldText: String, newText: String) -> [String: Any] {
        let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolved) else {
            Log.mcp.warning("Edit denied: path outside allowed folders: \(resolved)")
            return ["error": "Access denied: path is outside allowed folders"]
        }

        guard fm.fileExists(atPath: resolved) else {
            return ["error": "File not found: \(filePath)"]
        }

        guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
            return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
        }

        // Count occurrences
        let occurrences = content.components(separatedBy: oldText).count - 1
        if occurrences == 0 {
            return ["error": "old_text not found in file"]
        }
        if occurrences > 1 {
            return ["error": "old_text appears \(occurrences) times in the file — it must be unique (exactly 1 match). Provide more surrounding context to make it unique."]
        }

        let newContent = content.replacingOccurrences(of: oldText, with: newText)
        do {
            try newContent.write(toFile: resolved, atomically: true, encoding: .utf8)
        } catch {
            return ["error": "Cannot write file: \(error.localizedDescription)"]
        }

        return ["success": true, "path": resolved]
    }

    private func deleteFile(filePath: String) -> [String: Any] {
        let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolved) else {
            Log.mcp.warning("Delete denied: path outside allowed folders: \(resolved)")
            return ["error": "Access denied: path is outside allowed folders"]
        }

        guard fm.fileExists(atPath: resolved) else {
            return ["error": "File not found: \(filePath)"]
        }

        do {
            try fm.removeItem(atPath: resolved)
        } catch {
            Log.mcp.error("Failed to delete \(resolved): \(error.localizedDescription)")
            return ["error": "Cannot delete file: \(error.localizedDescription)"]
        }

        Log.mcp.info("Deleted \(resolved)")
        return ["success": true, "path": resolved]
    }

    private func createDirectory(dirPath: String) -> [String: Any] {
        let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolved) else {
            return ["error": "Access denied: path is outside allowed folders"]
        }

        do {
            try fm.createDirectory(atPath: resolved, withIntermediateDirectories: true)
        } catch {
            return ["error": "Cannot create directory: \(error.localizedDescription)"]
        }

        return ["success": true, "path": resolved]
    }

    private func moveFile(source: String, destination: String) -> [String: Any] {
        let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
        let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolvedSrc) else {
            return ["error": "Access denied: source path is outside allowed folders"]
        }
        guard isPathAllowed(resolvedDst) else {
            return ["error": "Access denied: destination path is outside allowed folders"]
        }
        guard fm.fileExists(atPath: resolvedSrc) else {
            return ["error": "Source not found: \(source)"]
        }

        // Create parent directory of destination if needed
        let parentDir = (resolvedDst as NSString).deletingLastPathComponent
        if !fm.fileExists(atPath: parentDir) {
            do {
                try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
            } catch {
                return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
            }
        }

        do {
            try fm.moveItem(atPath: resolvedSrc, toPath: resolvedDst)
        } catch {
            return ["error": "Cannot move file: \(error.localizedDescription)"]
        }

        return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
    }

    private func copyFile(source: String, destination: String) -> [String: Any] {
        let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
        let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath

        guard isPathAllowed(resolvedSrc) else {
            return ["error": "Access denied: source path is outside allowed folders"]
        }
        guard isPathAllowed(resolvedDst) else {
            return ["error": "Access denied: destination path is outside allowed folders"]
        }
        guard fm.fileExists(atPath: resolvedSrc) else {
            return ["error": "Source not found: \(source)"]
        }

        // Create parent directory of destination if needed
        let parentDir = (resolvedDst as NSString).deletingLastPathComponent
        if !fm.fileExists(atPath: parentDir) {
            do {
                try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
            } catch {
                return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
            }
        }

        do {
            try fm.copyItem(atPath: resolvedSrc, toPath: resolvedDst)
        } catch {
            return ["error": "Cannot copy file: \(error.localizedDescription)"]
        }

        return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
    }

    // MARK: - Bash Execution

    private func executeBashWithApproval(command: String, workingDirectory: String) async -> [String: Any] {
        // If the user already approved all commands for this session, skip the UI
        if bashSessionApproved {
            return await runBashCommand(command, workingDirectory: workingDirectory)
        }
        // 2nd Brain calls its helper script via bash_execute — let the user mark that
        // specific traffic as always-trusted instead of approving it every time.
        if isTrustedSecondBrainCommand(command) {
            return await runBashCommand(command, workingDirectory: workingDirectory)
        }
        return await withCheckedContinuation { continuation in
            DispatchQueue.main.async {
                self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
                self.pendingBashContinuation = continuation
            }
        }
    }

    func approvePendingBashCommand(forSession: Bool = false) {
        guard let pending = pendingBashCommand, let cont = pendingBashContinuation else { return }
        pendingBashCommand = nil
        pendingBashContinuation = nil
        if forSession {
            bashSessionApproved = true
        }
        Task.detached(priority: .userInitiated) {
            let result = await self.runBashCommand(pending.command, workingDirectory: pending.workingDirectory)
            cont.resume(returning: result)
        }
    }

    func denyPendingBashCommand() {
        guard pendingBashCommand != nil else { return }
        pendingBashCommand = nil
        pendingBashContinuation?.resume(returning: ["error": "User denied command execution"])
        pendingBashContinuation = nil
    }

    func resetBashSessionApproval() {
        bashSessionApproved = false
    }

    private func isTrustedSecondBrainCommand(_ command: String) -> Bool {
        guard settings.trustSecondBrainSkill, command.contains(".brain_helper.py") else { return false }
        return settings.agentSkills.contains { $0.isActive && $0.isSecondBrainSkill }
    }

    // MARK: - Research Agents (read-only, parallel)

    /// Runs `tasks.count` sub-agents (capped) with bounded concurrency, each in its own
    /// read-only tool loop, and returns their findings concatenated for the orchestrator.
    private func runResearchAgents(tasks: [String], provider: AIProvider, modelId: String) async -> [String: Any] {
        let maxConcurrent = max(1, min(5, settings.maxConcurrentAgents))
        // Hard cap on total sub-agents regardless of concurrency setting, so a model
        // requesting an unreasonably long task list can't run away with cost.
        let cappedTasks = Array(tasks.prefix(8))

        var results: [(Int, String)] = []
        await withTaskGroup(of: (Int, String).self) { group in
            var nextIndex = 0
            func launchNext() {
                guard nextIndex < cappedTasks.count else { return }
                let idx = nextIndex
                let task = cappedTasks[idx]
                nextIndex += 1
                group.addTask {
                    let answer = await self.runSingleResearchAgent(task: task, provider: provider, modelId: modelId)
                    return (idx, answer)
                }
            }
            for _ in 0..<min(maxConcurrent, cappedTasks.count) { launchNext() }
            for await result in group {
                results.append(result)
                launchNext()
            }
        }

        let sorted = results.sorted { $0.0 < $1.0 }
        let formatted = sorted.map { idx, answer in
            "### Agent \(idx + 1): \(cappedTasks[idx])\n\(answer)"
        }.joined(separator: "\n\n")

        var response: [String: Any] = ["agent_count": sorted.count, "results": formatted]
        if tasks.count > cappedTasks.count {
            response["note"] = "Only the first \(cappedTasks.count) of \(tasks.count) requested tasks were run (per-call cap)."
        }
        return response
    }

    /// A single sub-agent's self-contained tool loop. Read-only tools only; cannot write,
    /// run bash, or spawn further sub-agents (spawn_research_agents is not in its tool list).
    private func runSingleResearchAgent(task: String, provider: AIProvider, modelId: String) async -> String {
        let readOnlyTools: [Tool] = [
            makeTool(
                name: "read_file",
                description: "Read the contents of a file. PDFs are supported (text layer extracted automatically). Maximum file size is 10MB.",
                properties: ["file_path": prop("string", "The absolute path to the file to read")],
                required: ["file_path"]
            ),
            makeTool(
                name: "list_directory",
                description: "List the contents of a directory. Skips hidden/build directories like .git, node_modules, etc.",
                properties: [
                    "dir_path": prop("string", "The absolute path to the directory to list"),
                    "recursive": prop("boolean", "Whether to list recursively (default: false)")
                ],
                required: ["dir_path"]
            ),
            makeTool(
                name: "search_files",
                description: "Search for files by name pattern or content.",
                properties: [
                    "pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
                    "search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
                    "content_search": prop("string", "Optional text to search for inside files")
                ],
                required: ["pattern"]
            ),
            makeTool(
                name: "web_search",
                description: "Search the web for current information using DuckDuckGo.",
                properties: ["query": prop("string", "The search query to look up")],
                required: ["query"]
            )
        ]
        let allowedNames = Set(readOnlyTools.map { $0.function.name })

        var apiMessages: [[String: Any]] = [
            ["role": "system", "content": "You are a read-only research sub-agent. Investigate the assigned task using the available tools and report concise findings as plain text. You cannot write, delete, or execute anything, and cannot spawn further sub-agents. Once you have enough information, respond with your final answer and stop calling tools."],
            ["role": "user", "content": task]
        ]

        let maxIterations = 6
        for iteration in 0..<maxIterations {
            if Task.isCancelled { return "(cancelled)" }

            guard let response = try? await provider.chatWithToolMessages(
                model: modelId, messages: apiMessages, tools: readOnlyTools, maxTokens: nil, temperature: nil
            ) else {
                return "(error: sub-agent request failed)"
            }

            let toolCalls = response.toolCalls ?? []
            guard !toolCalls.isEmpty else {
                return response.content.isEmpty ? "(no findings)" : response.content
            }

            var assistantMsg: [String: Any] = ["role": "assistant"]
            if !response.content.isEmpty { assistantMsg["content"] = response.content }
            assistantMsg["tool_calls"] = toolCalls.map { tc in
                ["id": tc.id, "type": tc.type, "function": ["name": tc.functionName, "arguments": tc.arguments]]
            }
            apiMessages.append(assistantMsg)

            for tc in toolCalls {
                let resultJSON: String
                if allowedNames.contains(tc.functionName) {
                    let result = await executeTool(name: tc.functionName, arguments: tc.arguments)
                    resultJSON = serializeToolResult(result)
                } else {
                    resultJSON = "{\"error\": \"Tool not available to research sub-agents\"}"
                }
                apiMessages.append([
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "name": tc.functionName,
                    "content": resultJSON
                ])
            }

            if iteration == maxIterations - 1 {
                return "(research incomplete: sub-agent reached its iteration limit)"
            }
        }
        return "(no findings)"
    }

    private func serializeToolResult(_ result: [String: Any], maxBytes: Int = 20_000) -> String {
        guard let data = try? JSONSerialization.data(withJSONObject: result),
              let str = String(data: data, encoding: .utf8) else {
            return "{\"error\": \"Failed to serialize result\"}"
        }
        guard str.utf8.count > maxBytes, let truncated = String(str.utf8.prefix(maxBytes)) else {
            return str
        }
        return truncated + "\n... (result truncated)"
    }

    // MARK: - Personal Data (Calendar/Reminders) Approval

    private func executePersonalDataAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
        guard settings.personalDataRequireApproval, !personalDataSessionApproved else {
            return await eventKitService.executeWriteTool(name: toolName, arguments: argumentsJSON)
        }
        return await withCheckedContinuation { continuation in
            DispatchQueue.main.async {
                self.pendingPersonalDataAction = PendingPersonalDataAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
                self.pendingPersonalDataContinuation = continuation
            }
        }
    }

    func approvePendingPersonalDataAction(forSession: Bool = false) {
        guard let pending = pendingPersonalDataAction, let cont = pendingPersonalDataContinuation else { return }
        pendingPersonalDataAction = nil
        pendingPersonalDataContinuation = nil
        if forSession {
            personalDataSessionApproved = true
        }
        Task.detached(priority: .userInitiated) {
            let result = await self.eventKitService.executeWriteTool(name: pending.toolName, arguments: pending.argumentsJSON)
            cont.resume(returning: result)
        }
    }

    func denyPendingPersonalDataAction() {
        guard pendingPersonalDataAction != nil else { return }
        pendingPersonalDataAction = nil
        pendingPersonalDataContinuation?.resume(returning: ["error": "User denied this action"])
        pendingPersonalDataContinuation = nil
    }

    func resetPersonalDataSessionApproval() {
        personalDataSessionApproved = false
    }

    private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
        let timeoutSeconds = settings.bashTimeout
        let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
        Log.mcp.info("bash_execute: \(command)")

        return await withCheckedContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                let process = Process()
                process.executableURL = URL(fileURLWithPath: "/bin/zsh")
                process.arguments = ["-c", command]

                var isDir: ObjCBool = false
                if FileManager.default.fileExists(atPath: workDir, isDirectory: &isDir), isDir.boolValue {
                    process.currentDirectoryURL = URL(fileURLWithPath: workDir)
                }

                let stdoutPipe = Pipe()
                let stderrPipe = Pipe()
                process.standardOutput = stdoutPipe
                process.standardError = stderrPipe

                var timedOut = false
                let timeoutItem = DispatchWorkItem {
                    if process.isRunning {
                        timedOut = true
                        process.terminate()
                    }
                }
                DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(timeoutSeconds), execute: timeoutItem)

                do {
                    try process.run()
                    process.waitUntilExit()
                } catch {
                    timeoutItem.cancel()
                    Log.mcp.error("bash_execute failed to start: \(error.localizedDescription)")
                    continuation.resume(returning: ["error": "Failed to run command: \(error.localizedDescription)"])
                    return
                }

                timeoutItem.cancel()

                let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
                let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
                let exitCode = Int(process.terminationStatus)

                Log.mcp.info("bash_execute exit=\(exitCode) stdout=\(stdout.count)b stderr=\(stderr.count)b timedOut=\(timedOut)")

                var result: [String: Any] = ["exit_code": exitCode]
                if !stdout.isEmpty {
                    let maxOut = 20_000
                    result["stdout"] = stdout.count > maxOut
                        ? String(stdout.prefix(maxOut)) + "\n... (output truncated)"
                        : stdout
                }
                if !stderr.isEmpty {
                    result["stderr"] = String(stderr.prefix(5_000))
                }
                if timedOut {
                    result["timed_out"] = true
                    result["note"] = "Command terminated after \(timeoutSeconds)s timeout"
                }

                continuation.resume(returning: result)
            }
        }
    }

    // MARK: - Gitignore Support

    /// Reload gitignore rules for all allowed folders
    func reloadGitignores() {
        gitignoreRules.removeAll()
        if settings.mcpRespectGitignore {
            loadGitignores()
        }
    }

    private func loadGitignores() {
        for folder in allowedFolders {
            loadGitignoreForFolder(folder)
        }
    }

    private func loadGitignoreForFolder(_ folder: String) {
        var parser = GitignoreParser(rootPath: folder)
        parser.loadRules(fileManager: fm)
        gitignoreRules[folder] = parser
    }

    /// Check if an absolute path is gitignored by any loaded gitignore rules
    func isGitignored(_ absolutePath: String) -> Bool {
        guard settings.mcpRespectGitignore else { return false }

        for (folder, parser) in gitignoreRules {
            if absolutePath.hasPrefix(folder) {
                let relativePath = String(absolutePath.dropFirst(folder.count).drop(while: { $0 == "/" }))
                if !relativePath.isEmpty && parser.isIgnored(relativePath) {
                    return true
                }
            }
        }
        return false
    }
}

// MARK: - GitignoreParser

/// Parses .gitignore files and checks paths against the patterns.
/// Supports: wildcards (*), double wildcards (**), directory patterns (/), negation (!), comments (#).
struct GitignoreParser {
    let rootPath: String
    private var rules: [GitignoreRule] = []

    struct GitignoreRule {
        let pattern: String
        let isNegation: Bool
        let isDirectoryOnly: Bool
        /// Regex compiled from the gitignore glob pattern
        let regex: NSRegularExpression?
    }

    init(rootPath: String) {
        self.rootPath = rootPath
    }

    /// Load .gitignore from the root path (non-recursive — only the root .gitignore)
    mutating func loadRules(fileManager fm: FileManager) {
        let gitignorePath = (rootPath as NSString).appendingPathComponent(".gitignore")
        guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else {
            return
        }
        parseContent(content)
    }

    mutating func parseContent(_ content: String) {
        let lines = content.components(separatedBy: .newlines)
        for line in lines {
            let trimmed = line.trimmingCharacters(in: .whitespaces)
            // Skip empty lines and comments
            if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }

            var pattern = trimmed
            let isNegation = pattern.hasPrefix("!")
            if isNegation {
                pattern = String(pattern.dropFirst())
            }

            // Remove trailing spaces (unless escaped)
            while pattern.hasSuffix(" ") && !pattern.hasSuffix("\\ ") {
                pattern = String(pattern.dropLast())
            }

            let isDirectoryOnly = pattern.hasSuffix("/")
            if isDirectoryOnly {
                pattern = String(pattern.dropLast())
            }

            // Remove leading slash (anchors to root, but we match relative paths)
            if pattern.hasPrefix("/") {
                pattern = String(pattern.dropFirst())
            }

            guard !pattern.isEmpty else { continue }

            let regex = gitignorePatternToRegex(pattern)
            rules.append(GitignoreRule(
                pattern: pattern,
                isNegation: isNegation,
                isDirectoryOnly: isDirectoryOnly,
                regex: regex
            ))
        }
    }

    /// Check if a relative path (from rootPath) is ignored
    func isIgnored(_ relativePath: String) -> Bool {
        var ignored = false
        for rule in rules {
            let matches: Bool
            if let regex = rule.regex {
                let range = NSRange(relativePath.startIndex..., in: relativePath)
                matches = regex.firstMatch(in: relativePath, range: range) != nil
            } else {
                // Fallback: simple contains check for the pattern basename
                let basename = (relativePath as NSString).lastPathComponent
                matches = basename == rule.pattern
            }

            if matches {
                if rule.isNegation {
                    ignored = false
                } else {
                    ignored = true
                }
            }
        }
        return ignored
    }

    /// Convert a gitignore glob pattern to a regex
    private func gitignorePatternToRegex(_ pattern: String) -> NSRegularExpression? {
        var regex = ""
        let chars = Array(pattern)
        var i = 0

        // If the pattern contains no slash, it matches against the filename only
        let matchesPath = pattern.contains("/")

        if !matchesPath {
            // Match against any path component — the pattern can appear as the last component
            regex += "(?:^|/)"
        } else {
            regex += "^"
        }

        while i < chars.count {
            let c = chars[i]
            switch c {
            case "*":
                if i + 1 < chars.count && chars[i + 1] == "*" {
                    // **
                    if i + 2 < chars.count && chars[i + 2] == "/" {
                        // **/  — matches zero or more directories
                        regex += "(?:.+/)?"
                        i += 3
                        continue
                    } else {
                        // ** at end — matches everything
                        regex += ".*"
                        i += 2
                        continue
                    }
                } else {
                    // Single * — matches anything except /
                    regex += "[^/]*"
                }
            case "?":
                regex += "[^/]"
            case ".":
                regex += "\\."
            case "[":
                // Character class — pass through
                regex += "["
            case "]":
                regex += "]"
            case "\\":
                // Escape next character
                if i + 1 < chars.count {
                    i += 1
                    regex += NSRegularExpression.escapedPattern(for: String(chars[i]))
                }
            default:
                regex += NSRegularExpression.escapedPattern(for: String(c))
            }
            i += 1
        }

        // Allow matching as a prefix (directory) or exact match
        regex += "(?:/.*)?$"

        return try? NSRegularExpression(pattern: regex, options: [])
    }
}
