//
//  MCPService.swift
//  oAI
//
//  MCP (Model Context Protocol) service for filesystem tool execution
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.


import Foundation
import os

@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
        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

    // 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: - 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. 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.",
                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 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"]
            ))
        }

        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

    func executeTool(name: String, arguments: String) 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]

        default:
            // 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)
            }
            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)"]
        }

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

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

    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 {
                guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
                    continue
                }
                if !content.localizedCaseInsensitiveContains(searchText) {
                    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)
        }
        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 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: [])
    }
}
