import Foundation

// MARK: - Error Type

enum ToolError: LocalizedError {
    case missingParameter(String)
    case executionFailed(String)
    case unsupportedTool(String)

    var errorDescription: String? {
        switch self {
        case .missingParameter(let p): "Missing parameter: \(p)"
        case .executionFailed(let msg): msg
        case .unsupportedTool(let t): "Unsupported tool: \(t)"
        }
    }
}

// MARK: - Handler Protocol

protocol ToolHandler: Sendable {
    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String
}

// MARK: - Shell

struct ShellHandler: ToolHandler {
    /// Max wall-clock before a FOREGROUND command is dealt with. Long enough for
    /// real installs/builds (`npm install`), bounded so a hang can't stall the
    /// agent. Injectable so tests can use a short bound.
    var timeoutSeconds: Double = 120

    /// When present, `run_in_background:"true"` registers a managed process and
    /// returns instantly, and a foreground command still alive at the timeout is
    /// ADOPTED (never killed) instead of being terminated. Absent (e.g. older
    /// call sites / unit tests that don't inject one) → today's behavior:
    /// foreground only, killed at the timeout.
    var registry: ProcessRegistry? = nil
    /// The chat session the spawned process belongs to (for scoped cleanup).
    var sessionId: UUID? = nil
    /// Out-parameter for the handle of any process started/adopted by this call,
    /// so the caller can surface it on `ToolResult.backgroundHandle`.
    var handleBox: ProcessHandleBox? = nil

    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        guard let command = parameters["command"] else {
            throw ToolError.missingParameter("command")
        }
        let cwd = workingDirectory ?? FileManager.default.currentDirectoryPath

        // Background detection is model-agnostic: either the explicit flag OR a
        // trailing `&` in the command. Small models won't set the flag — they
        // just write `python3 -m http.server &` — so we treat that identically.
        // The flag itself is read leniently because models send `true`, `"true"`,
        // `1`, `"1"`, `yes`, … (all stringify into arguments) — accepting only
        // the literal `"true"` silently dropped capable models into the 120s
        // foreground/adopt path. No prompt-following required; handled in code.
        let wantsBackground = Self.isTruthyFlag(parameters["run_in_background"])
        let hasTrailingAmp = Self.hasTrailingBackgroundOperator(command)
        if registry != nil, wantsBackground || hasTrailingAmp {
            return await startInBackground(command: command, cwd: cwd, workingDirectory: workingDirectory)
        }
        // Explicit flag but no registry wired → graceful error (preserves the
        // old contract). A bare `&` with no registry just runs foreground as
        // before — nothing to manage it, but no worse than today.
        if wantsBackground {
            return ShellMessages.backgroundUnavailable(cwd: cwd, seconds: Int(timeoutSeconds))
        }
        return try await runForeground(command: command, cwd: cwd, workingDirectory: workingDirectory)
    }

    /// Register a long-lived command with the process registry and return at once
    /// so the assistant keeps talking while it runs.
    private func startInBackground(command: String, cwd: String, workingDirectory: String?) async -> String {
        guard let registry else {
            return ShellMessages.backgroundUnavailable(cwd: cwd, seconds: Int(timeoutSeconds))
        }
        // Strip a trailing `&`: backgrounding INSIDE the login shell makes the
        // shell exit immediately, so the tracked pid becomes a dead shell while
        // the real server is orphaned beyond the registry's reach (killProcess /
        // kill X / session+quit cleanup all silently miss it). Stripping it lets
        // the shell exec-replace into the process so the tracked pid IS the
        // server (and child cleanup walks the live subtree — see ProcessRegistry).
        let cleaned = Self.stripTrailingBackgroundOperator(command)
        let info: (handle: String, pid: Int32) = await MainActor.run {
            let managed = registry.start(command: cleaned, workingDirectory: workingDirectory, sessionId: sessionId)
            return (managed.handle, managed.pid)
        }
        handleBox?.set(info.handle)
        return ShellMessages.started(cwd: cwd, handle: info.handle, pid: info.pid)
    }

    /// Lenient truthy read for a string-typed boolean tool flag. Tool arguments
    /// are stringified, and models emit `true`/`1`/`yes`/`on` (any casing) — so
    /// the flag must accept all of them, not just the literal `"true"`.
    static func isTruthyFlag(_ value: String?) -> Bool {
        guard let v = value?.trimmingCharacters(in: .whitespaces).lowercased() else { return false }
        return v == "true" || v == "1" || v == "yes" || v == "y" || v == "on"
    }

    /// True when the command ends with a single shell background operator (`… &`)
    /// — the model's "run this in the background" signal, with or without the
    /// `run_in_background` flag. `&&` (logical-AND) is not a background operator.
    static func hasTrailingBackgroundOperator(_ command: String) -> Bool {
        let trimmed = command.trimmingCharacters(in: .whitespaces)
        return trimmed.hasSuffix("&") && !trimmed.hasSuffix("&&")
    }

    /// Drop a single trailing shell background operator (`… &`). Leaves `&&` and
    /// inner `&` untouched. Pure + testable.
    static func stripTrailingBackgroundOperator(_ command: String) -> String {
        guard hasTrailingBackgroundOperator(command) else { return command }
        let trimmed = command.trimmingCharacters(in: .whitespaces)
        return String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
    }

    /// Foreground execution with the timeout backstop. A command still alive at
    /// the timeout is adopted by the registry (never killed) when one is present;
    /// with no registry it's killed and reported — today's behavior.
    private func runForeground(command: String, cwd: String, workingDirectory: String?) async throws -> String {
        let timeout = timeoutSeconds
        let registry = self.registry
        let sessionId = self.sessionId
        let handleBox = self.handleBox

        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
            DispatchQueue.global(qos: .userInitiated).async {
                let process = ProcessRegistry.makeProcess(command: command, workingDirectory: workingDirectory)
                let outPipe = process.standardOutput as! Pipe
                let errPipe = process.standardError as! Pipe

                // Read both pipes incrementally so a kill never blocks on
                // `readDataToEndOfFile` waiting for orphaned grandchildren
                // (e.g. node spawned by npx) to release the write end.
                let cap = ShellCapture()
                outPipe.fileHandleForReading.readabilityHandler = { h in cap.appendOut(h.availableData) }
                errPipe.fileHandleForReading.readabilityHandler = { h in cap.appendErr(h.availableData) }

                // Signal exit via terminationHandler + a bounded semaphore wait
                // (NOT waitUntilExit) so a never-exiting server releases this
                // worker thread at the timeout instead of leaking it forever.
                let exitSem = DispatchSemaphore(value: 0)
                process.terminationHandler = { _ in exitSem.signal() }

                do {
                    try process.run()
                } catch {
                    continuation.resume(throwing: error)
                    return
                }
                let pid = process.processIdentifier

                // Single-resume guard: the adopt branch resumes from a hopped
                // MainActor task, so a near-simultaneous real exit must not
                // double-resume the continuation.
                let resumed = ManagedAtomicFlag()
                let finish: (String) -> Void = { s in
                    if resumed.testAndSet() { continuation.resume(returning: s) }
                }

                let timedOut = exitSem.wait(timeout: .now() + timeout) == .timedOut

                // Raced exit: the wait timed out but the process actually
                // finished right at the deadline → fall through to clean completion.
                if timedOut && process.isRunning {
                    outPipe.fileHandleForReading.readabilityHandler = nil
                    errPipe.fileHandleForReading.readabilityHandler = nil
                    if let registry {
                        let prior = cap.combined()
                        Task { @MainActor in
                            let managed = registry.adopt(process: process, command: command,
                                                         workingDirectory: workingDirectory,
                                                         sessionId: sessionId, priorOutput: prior)
                            handleBox?.set(managed.handle)
                            finish(ShellMessages.adopted(cwd: cwd, handle: managed.handle,
                                                         pid: managed.pid, seconds: Int(timeout)))
                        }
                    } else {
                        process.terminate()
                        kill(pid, SIGKILL)
                        finish(ShellMessages.timedOutKilled(cwd: cwd, seconds: Int(timeout),
                                                            body: cap.foregroundBody()))
                    }
                    return
                }

                // Clean exit (or raced exit at the deadline).
                process.terminationHandler = nil
                outPipe.fileHandleForReading.readabilityHandler = nil
                errPipe.fileHandleForReading.readabilityHandler = nil
                // On a clean exit the pipe EOFs promptly, so drain any final
                // chunk the handler hadn't delivered yet.
                cap.appendOut(try? outPipe.fileHandleForReading.readToEnd())
                cap.appendErr(try? errPipe.fileHandleForReading.readToEnd())

                finish(ShellMessages.completed(cwd: cwd, body: cap.foregroundBody(),
                                               exitCode: process.terminationStatus))
            }
        }
    }
}

/// Thread-safe stdout/stderr accumulator for the foreground shell path. Keeps the
/// readability-handler closures trivial and the message-shaping logic out of the
/// large `runForeground` body the type-checker has to chew through.
final class ShellCapture: @unchecked Sendable {
    private let lock = NSLock()
    private var out = Data()
    private var err = Data()

    func appendOut(_ d: Data?) { guard let d, !d.isEmpty else { return }; lock.lock(); out.append(d); lock.unlock() }
    func appendErr(_ d: Data?) { guard let d, !d.isEmpty else { return }; lock.lock(); err.append(d); lock.unlock() }

    private func snapshot() -> (String, String) {
        lock.lock(); defer { lock.unlock() }
        return (String(data: out, encoding: .utf8) ?? "", String(data: err, encoding: .utf8) ?? "")
    }

    /// stdout followed by stderr — what the registry seeds an adopted process with.
    func combined() -> String {
        let (o, e) = snapshot()
        return e.isEmpty ? o : o + e
    }

    /// stdout with a trailing `[stderr]: …` section — the foreground result shape.
    func foregroundBody() -> String {
        let (o, e) = snapshot()
        return e.isEmpty ? o : o + "\n[stderr]: \(e)"
    }
}

/// Shell tool result strings. Pulled out of the handler so each long interpolated
/// message is type-checked as an isolated, trivial `String` return instead of
/// inside the nested closures of `runForeground` — the latter caused a
/// pathological (multi-minute) type-check of `ShellHandler.execute`.
enum ShellMessages {
    static func started(cwd: String, handle: String, pid: Int32) -> String {
        "[cwd: \(cwd)]\nStarted in background as \(handle) (pid \(pid)). It keeps running — poll it with readProcessOutput {\"handle\": \"\(handle)\"}, stop it with killProcess {\"handle\": \"\(handle)\"}."
    }

    static func backgroundUnavailable(cwd: String, seconds: Int) -> String {
        "[cwd: \(cwd)]\nError: background execution isn't available in this context. Run the command in the foreground, or it will be stopped at the \(seconds)s timeout."
    }

    static func adopted(cwd: String, handle: String, pid: Int32, seconds: Int) -> String {
        "[cwd: \(cwd)]\nStill running after \(seconds)s — now managed in the background as \(handle) (pid \(pid)), NOT killed. Poll it with readProcessOutput {\"handle\": \"\(handle)\"}, stop it with killProcess {\"handle\": \"\(handle)\"}."
    }

    static func timedOutKilled(cwd: String, seconds: Int, body: String) -> String {
        let note = "[timed out after \(seconds)s and was killed. If this command waits for input, re-run it with non-interactive flags — it cannot read stdin. If it is a long-running server, start it with run_in_background:\"true\".]"
        return "[cwd: \(cwd)]\n\(body)\n\(note)"
    }

    static func completed(cwd: String, body: String, exitCode: Int32) -> String {
        var result = body
        if exitCode != 0 {
            result += "\n[exit code: \(exitCode)]"
        } else if result.isEmpty {
            result = "OK"
        }
        return "[cwd: \(cwd)]\n\(result)"
    }
}

/// Minimal thread-safe test-and-set flag — guards the shell continuation against
/// a double resume when the adopt branch and a near-simultaneous real exit race.
final class ManagedAtomicFlag: @unchecked Sendable {
    private let lock = NSLock()
    private var set = false
    /// Returns true exactly once (the first caller); false thereafter.
    func testAndSet() -> Bool {
        lock.lock()
        defer { lock.unlock() }
        if set { return false }
        set = true
        return true
    }
}

// MARK: - Read File

struct ReadFileHandler: ToolHandler {
    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        guard let path = parameters["path"] else {
            throw ToolError.missingParameter("path")
        }

        let fullPath = try resolveAndConfine(path, workingDirectory: workingDirectory)
        guard let data = FileManager.default.contents(atPath: fullPath),
              let content = String(data: data, encoding: .utf8) else {
            throw ToolError.executionFailed("Cannot read file: \(fullPath)")
        }

        let lines = content.components(separatedBy: "\n")
        let totalLines = lines.count
        let startLine = Int(parameters["startLine"] ?? "1") ?? 1
        let endLine = Int(parameters["endLine"] ?? "\(totalLines)") ?? totalLines
        let actualStart = max(1, startLine)
        let actualEnd = min(totalLines, endLine)

        guard actualStart <= actualEnd else {
            return "Invalid line range: \(startLine)-\(endLine) (file has \(totalLines) lines)"
        }

        let slice = lines[actualStart - 1..<actualEnd]
        // Add line numbers so the model can reference specific lines for editFile
        var numbered = slice.enumerated().map { (i, line) in
            "\(actualStart + i)| \(line)"
        }.joined(separator: "\n")

        // Add metadata header for large files so model knows to use line ranges
        if totalLines > 200 || content.utf8.count > 6000 {
            let header = "[File: \(path) | Lines: \(actualStart)-\(actualEnd) of \(totalLines) | \(content.utf8.count) bytes"
            if actualEnd < totalLines {
                numbered = header + " | Use startLine/endLine to read more]\n" + numbered
            } else {
                numbered = header + "]\n" + numbered
            }
        }

        return numbered
    }
}

// MARK: - Write File

struct WriteFileHandler: ToolHandler {
    /// Tolerant boolean for the `append` flag. Models emit it dirty — `"true"`,
    /// `"true,"` (gemma-4-12b adds a trailing comma), `"True"`, even a leftover
    /// `"true,\n…"` before normalization peels the body off. Treat any value
    /// whose first token is `true` (or `1`/`yes`) as append; an exact `== "true"`
    /// match silently OVERWROTE the file when the model sent `"true,"`.
    static func appendFlagIsTrue(_ raw: String?) -> Bool {
        guard let raw else { return false }
        let v = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        if v == "true" || v == "1" || v == "yes" { return true }
        // "true" followed immediately by a non-letter (comma/space/newline) →
        // still append; "truely"/"truthy" (next char a letter) → not append.
        if v.hasPrefix("true"), let after = v.dropFirst(4).first, !after.isLetter { return true }
        return false
    }

    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        guard let path = parameters["path"], let content = parameters["content"] else {
            throw ToolError.missingParameter("path and content")
        }
        // append:true grows a file incrementally — the safe way to write a large
        // file across multiple tool calls without any one call overrunning the
        // token budget and getting truncated mid-write (see the writeFile tool
        // description). Default is overwrite, matching prior behavior. Parsed
        // tolerantly (see appendFlagIsTrue) — models dirty the flag value.
        let append = Self.appendFlagIsTrue(parameters["append"])

        let fullPath = try resolveAndConfine(path, workingDirectory: workingDirectory)
        let dir = (fullPath as NSString).deletingLastPathComponent
        try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)

        if append, FileManager.default.fileExists(atPath: fullPath) {
            let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: fullPath))
            defer { try? handle.close() }
            try handle.seekToEnd()
            try handle.write(contentsOf: Data(content.utf8))
            return "Appended \(content.count) characters to \(path)"
        }
        // Overwrite, or create the file when appending to one that doesn't exist yet.
        try content.write(toFile: fullPath, atomically: true, encoding: .utf8)
        return "Wrote \(content.count) characters to \(path)\(append ? " (new file)" : "")"
    }
}

// MARK: - Edit File

struct EditFileHandler: ToolHandler {
    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        guard let path = parameters["path"] else {
            throw ToolError.missingParameter("path")
        }

        let fullPath = try resolveAndConfine(path, workingDirectory: workingDirectory)
        guard let data = FileManager.default.contents(atPath: fullPath),
              var content = String(data: data, encoding: .utf8) else {
            throw ToolError.executionFailed("Cannot read file: \(fullPath)")
        }

        // Line-number-based editing: startLine/endLine + replace
        if let startStr = parameters["startLine"], let startLine = Int(startStr) {
            guard let replace = parameters["replace"] else {
                throw ToolError.executionFailed("editFile with startLine/endLine requires 'replace' parameter. You sent startLine=\(startStr) but no replace content. Example: {\"path\": \"file.js\", \"startLine\": \"5\", \"endLine\": \"8\", \"replace\": \"new code\"}")
            }
            let lines = content.components(separatedBy: "\n")
            let endLine = Int(parameters["endLine"] ?? startStr) ?? startLine
            let actualStart = max(1, startLine)
            let actualEnd = min(lines.count, endLine)

            guard actualStart <= actualEnd else {
                throw ToolError.executionFailed("Invalid line range: \(startLine)-\(endLine) (file has \(lines.count) lines)")
            }

            var newLines = Array(lines[0..<(actualStart - 1)])
            newLines.append(contentsOf: replace.components(separatedBy: "\n"))
            if actualEnd < lines.count {
                newLines.append(contentsOf: lines[actualEnd...])
            }
            content = newLines.joined(separator: "\n")
            try content.write(toFile: fullPath, atomically: true, encoding: .utf8)
            return "Edited \(path) (replaced lines \(actualStart)-\(actualEnd))"
        }

        // Text-based editing: find + replace
        guard let find = parameters["find"], !find.isEmpty else {
            throw ToolError.missingParameter("Either 'find' or 'startLine' is required")
        }
        let replace = parameters["replace"] ?? ""

        guard content.contains(find) else {
            // Show nearby content to help the model correct its find pattern
            let lines = content.components(separatedBy: "\n")
            let preview = lines.prefix(10).enumerated()
                .map { "\($0.offset + 1)| \($0.element)" }.joined(separator: "\n")
            throw ToolError.executionFailed(
                "Pattern not found in \(path). Use readFile first to see exact content, or use startLine/endLine for line-based editing. First 10 lines:\n\(preview)"
            )
        }

        content = content.replacingOccurrences(of: find, with: replace)
        try content.write(toFile: fullPath, atomically: true, encoding: .utf8)
        return "Edited \(path)"
    }
}

// MARK: - Search Files

struct SearchFilesHandler: ToolHandler {
    private let shellHandler = ShellHandler()

    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        guard let pattern = parameters["pattern"] else {
            throw ToolError.missingParameter("pattern")
        }
        let path = parameters["path"] ?? "."
        // Confine search path to workspace
        let confinedPath = try resolveAndConfine(path, workingDirectory: workingDirectory)
        let maxResults = Int(parameters["maxResults"] ?? "100") ?? 100
        let escaped = shellEscape(pattern)
        let escapedPath = shellEscape(confinedPath)

        // Use ripgrep if available, fallback to grep
        let useRg = FileManager.default.fileExists(atPath: "/opt/homebrew/bin/rg")
            || FileManager.default.fileExists(atPath: "/usr/local/bin/rg")

        var command: String
        if useRg {
            command = "rg -n --no-heading"
            if let include = parameters["include"] {
                command += " -g \(shellEscape(include))"
            }
            if let context = parameters["context"], let ctx = Int(context), ctx > 0 {
                command += " -C \(min(ctx, 10))"
            }
            command += " \(escaped) \(escapedPath) 2>/dev/null | head -\(maxResults)"
        } else {
            command = "grep -rn"
            if let include = parameters["include"] {
                command += " --include=\(shellEscape(include))"
            }
            command += " \(escaped) \(escapedPath) 2>/dev/null | head -\(maxResults)"
        }
        return try await shellHandler.execute(parameters: ["command": command], workingDirectory: workingDirectory)
    }

    private func shellEscape(_ s: String) -> String {
        "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
    }
}


// MARK: - List Files

struct ListFilesHandler: ToolHandler {
    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        let path = parameters["path"] ?? "."
        let fullPath = try resolveAndConfine(path, workingDirectory: workingDirectory)
        let pattern = parameters["pattern"]
        let recursive = parameters["recursive"]?.lowercased() == "true"

        let fm = FileManager.default
        guard fm.fileExists(atPath: fullPath) else {
            throw ToolError.executionFailed("Directory not found: \(fullPath)")
        }

        var entries: [String] = []

        if recursive {
            guard let enumerator = fm.enumerator(atPath: fullPath) else {
                throw ToolError.executionFailed("Cannot enumerate: \(fullPath)")
            }
            // If pattern has no path separators (e.g. "*.swift"), match filename only
            let matchFilenameOnly = pattern != nil && !pattern!.contains("/")
            while let item = enumerator.nextObject() as? String {
                if let pattern {
                    let target = matchFilenameOnly ? (item as NSString).lastPathComponent : item
                    if matchesGlob(target, pattern: pattern) {
                        entries.append(item)
                    }
                } else {
                    entries.append(item)
                }
                if entries.count >= 200 { break }
            }
        } else {
            let items = try fm.contentsOfDirectory(atPath: fullPath)
            for item in items.sorted() {
                if let pattern {
                    if matchesGlob(item, pattern: pattern) {
                        entries.append(item)
                    }
                } else {
                    entries.append(item)
                }
                if entries.count >= 200 { break }
            }
        }

        if entries.isEmpty {
            return "No files found in \(path)" + (pattern != nil ? " matching '\(pattern!)'" : "")
        }
        let result = entries.joined(separator: "\n")
        let suffix = entries.count >= 200 ? "\n[... truncated at 200 entries]" : ""
        return result + suffix
    }

    /// Simple glob matching supporting * and ** wildcards.
    private func matchesGlob(_ path: String, pattern: String) -> Bool {
        // Convert glob to regex: * matches non-slash, ** matches anything
        var regex = "^"
        var i = pattern.startIndex
        while i < pattern.endIndex {
            let c = pattern[i]
            if c == "*" {
                let next = pattern.index(after: i)
                if next < pattern.endIndex && pattern[next] == "*" {
                    regex += ".*"
                    i = pattern.index(after: next)
                    // Skip trailing slash after **
                    if i < pattern.endIndex && pattern[i] == "/" {
                        i = pattern.index(after: i)
                    }
                    continue
                } else {
                    regex += "[^/]*"
                }
            } else if c == "?" {
                regex += "[^/]"
            } else if c == "." {
                regex += "\\."
            } else {
                regex += String(c)
            }
            i = pattern.index(after: i)
        }
        regex += "$"
        return path.range(of: regex, options: .regularExpression) != nil
    }
}

// MARK: - Browser tool timeout

/// Hard ceiling on any single browser-tool invocation. The inner WKWebView calls
/// already have their own navigate/evaluateJS timeouts, but a page can still
/// hang in ways that freeze the agent loop — this guarantees the tool returns
/// (with an error) within a bounded window so the loop keeps moving.
private let browserToolTimeoutSeconds: UInt64 = 30

private func withBrowserToolTimeout<T: Sendable>(
    _ description: String,
    _ body: @escaping @Sendable () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await body() }
        group.addTask {
            try await Task.sleep(nanoseconds: browserToolTimeoutSeconds * 1_000_000_000)
            throw ToolError.executionFailed("\(description) timed out after \(browserToolTimeoutSeconds)s")
        }
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

// MARK: - Web Search (DuckDuckGo)

struct WebSearchHandler: ToolHandler {
    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        guard let query = parameters["query"], !query.isEmpty else {
            throw ToolError.missingParameter("query")
        }
        let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
        let url = "https://html.duckduckgo.com/html/?q=\(encoded)"

        return try await withBrowserToolTimeout("webSearch") {
            let browser = await BrowserManager.shared
            _ = try await browser.navigate(to: url)
            // Wait for search results to render
            try await Task.sleep(nanoseconds: 500_000_000)

            // Extract structured search results instead of raw page text
            let js = """
            (function() {
                var results = [];
                var links = document.querySelectorAll('.result__a, .result__title a, a.result-link');
                if (links.length === 0) links = document.querySelectorAll('a[href*="//"]');
                var seen = new Set();
                for (var i = 0; i < Math.min(links.length, 8); i++) {
                    var a = links[i];
                    var href = a.href || '';
                    if (href.includes('duckduckgo.com') || seen.has(href)) continue;
                    seen.add(href);
                    var title = (a.textContent || '').trim();
                    var snippet = '';
                    var parent = a.closest('.result') || a.closest('.web-result') || a.parentElement;
                    if (parent) {
                        var snipEl = parent.querySelector('.result__snippet, .result-snippet');
                        if (snipEl) snippet = snipEl.textContent.trim();
                    }
                    if (title && href) results.push(title + '\\n' + href + (snippet ? '\\n' + snippet : ''));
                }
                return results.length > 0 ? results.join('\\n\\n') : document.body.innerText.substring(0, 2000);
            })()
            """
            let result = try await browser.evaluateJS(js)
            return "Search results for '\(query)':\n\n\(result)"
        }
    }
}

// MARK: - Built-in Browser (WKWebView)

struct BrowseHandler: ToolHandler {
    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        let action = parameters["action"] ?? "navigate"

        return try await withBrowserToolTimeout("browse(\(action))") {
            let browser = await BrowserManager.shared

            switch action {
            case "navigate":
                guard let url = parameters["url"] else { throw ToolError.missingParameter("url") }
                return try await browser.navigate(to: url)
            case "readText":
                // Navigate to URL first if provided, then read text
                if let url = parameters["url"] {
                    _ = try await browser.navigate(to: url)
                }
                return try await browser.readText()
            case "readHTML":
                if let url = parameters["url"] {
                    _ = try await browser.navigate(to: url)
                }
                return try await browser.readHTML()
            case "extractText":
                if let url = parameters["url"] { _ = try await browser.navigate(to: url) }
                guard let selector = parameters["selector"] else { throw ToolError.missingParameter("selector") }
                return try await browser.extractText(selector: selector)
            case "click":
                guard let selector = parameters["selector"] else { throw ToolError.missingParameter("selector") }
                return try await browser.click(selector: selector)
            case "screenshot":
                if let url = parameters["url"] {
                    _ = try await browser.navigate(to: url)
                }
                guard let data = await browser.takeScreenshot() else {
                    return "Failed to capture screenshot"
                }
                let base64 = data.base64EncodedString()
                return "[screenshot:\(data.count) bytes]\ndata:image/jpeg;base64,\(base64)"
            case "getInfo":
                return try await browser.getInfo()
            case "executeJS":
                guard let script = parameters["script"] ?? parameters["expression"] else {
                    throw ToolError.missingParameter("script")
                }
                return try await browser.evaluateJS(script)
            default:
                // Fallback: if URL is present, treat as navigate
                if let url = parameters["url"] {
                    return try await browser.navigate(to: url)
                }
                return try await browser.readText()
            }
        }
    }
}


// MARK: - Save Memory

struct SaveMemoryHandler: ToolHandler {
    func execute(parameters: [String: String], workingDirectory: String?) async throws -> String {
        guard let memory = parameters["memory"] else {
            throw ToolError.missingParameter("memory")
        }
        AgentPrompt.saveMemory(memory)
        return "OK"
    }
}

// MARK: - Shared Helpers

private func resolvePath(_ path: String, workingDirectory: String?) -> String {
    if path.hasPrefix("/") || path.hasPrefix("~") {
        return NSString(string: path).expandingTildeInPath
    }
    if let wd = workingDirectory {
        return (wd as NSString).appendingPathComponent(path)
    }
    return path
}

/// Resolve a path and verify it stays within the working directory.
/// Returns the resolved absolute path, or throws if it escapes the workspace.
private func resolveAndConfine(_ path: String, workingDirectory: String?) throws -> String {
    let resolved: String
    if path.hasPrefix("/") || path.hasPrefix("~") {
        resolved = NSString(string: path).expandingTildeInPath
    } else if let wd = workingDirectory {
        resolved = (wd as NSString).appendingPathComponent(path)
    } else {
        return path // no workspace set, can't enforce
    }

    // Normalize to resolve ".." and symlinks for accurate prefix check
    let normalizedResolved = (resolved as NSString).standardizingPath

    guard let wd = workingDirectory else { return normalizedResolved }
    let normalizedWd = (wd as NSString).standardizingPath

    guard normalizedResolved == normalizedWd || normalizedResolved.hasPrefix(normalizedWd + "/") else {
        throw ToolError.executionFailed("Access denied: path '\(path)' resolves to '\(normalizedResolved)' which is outside the workspace '\(normalizedWd)'")
    }

    return normalizedResolved
}

// MARK: - Executor

@MainActor
class ToolExecutor: ObservableObject {
    @Published var currentStepIndex: Int?
    @Published var results: [StepResult] = []
    @Published var isExecuting = false

    private let handlers: [AgentToolKind: any ToolHandler] = [
        .shell: ShellHandler(),
        .readFile: ReadFileHandler(),
        .writeFile: WriteFileHandler(),
        .editFile: EditFileHandler(),
        .searchFiles: SearchFilesHandler(),
        .listFiles: ListFilesHandler(),
        .browse: BrowseHandler(),
        .webSearch: WebSearchHandler(),
        .saveMemory: SaveMemoryHandler(),
    ]

    func executePlan(_ plan: AgentPlan, workingDirectory: String?) async -> [StepResult] {
        isExecuting = true
        results = []

        for (index, step) in plan.steps.enumerated() {
            currentStepIndex = index
            let start = DispatchTime.now()

            do {
                // Smart fallback: if editFile is called with content but no find/replace, use writeFile
                let effectiveTool: AgentToolKind
                if step.tool == .editFile && step.parameters["content"] != nil && step.parameters["find"] == nil {
                    effectiveTool = .writeFile
                } else {
                    effectiveTool = step.tool
                }
                guard let handler = handlers[effectiveTool] else {
                    throw ToolError.unsupportedTool(step.tool.rawValue)
                }
                let output = try await handler.execute(parameters: step.parameters, workingDirectory: workingDirectory)
                let elapsed = Int64((DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000)
                results.append(StepResult(stepId: step.id, status: .success, output: output, durationMs: elapsed))
            } catch {
                let elapsed = Int64((DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000)
                results.append(StepResult(stepId: step.id, status: .failed, output: "", error: error.localizedDescription, durationMs: elapsed))
            }
        }

        currentStepIndex = nil
        isExecuting = false
        return results
    }
}
