//
//  AppleMailService.swift
//  Confab
//
//  Mail search/read/attachment access via AppleScript/Apple Events to Mail.app
//
// 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 Carbon
import Foundation

/// One matched message from `mail_search` — a flat, positionally-decoded projection of an
/// AppleScript record. Field order MUST match `AppleMailService.scriptSource`'s
/// `searchMessages` handler's returned list order.
struct MailSearchResult: Sendable, Equatable {
    let messageId: String
    let subject: String
    let from: String
    let dateReceived: String
    let mailbox: String
    let account: String
    let attachmentCount: String
}

enum AppleMailError: Error, Sendable {
    case scriptNotCompiled
    case appleScriptError(number: Int, message: String)
}

@Observable
final class AppleMailService {
    static let shared = AppleMailService()

    private init() {}

    // Apple Events to Mail.app block on IPC round-trip latency, and NSAppleScript isn't
    // documented safe for concurrent use of a single compiled instance — serialize all calls
    // through one queue, mirroring MCPService.runBashCommand's off-main-actor pattern.
    private let queue = DispatchQueue(label: "com.oai.applemail", qos: .userInitiated)

    // MARK: - Automation Permission (Settings UI status badge + Request Access button)

    /// Live tri-state permission check via `AEDeterminePermissionToAutomateTarget`, which — unlike
    /// sending an actual Apple Event — can query Automation permission for Mail.app without
    /// triggering any script execution or the system prompt (`askUserIfNeeded: false`). This is
    /// the Apple-Events equivalent of `EKEventStore.authorizationStatus(for:)`, reusing the same
    /// `PersonalDataAccessState` enum EventKitService/ContactsService/LocationMapsService use.
    var accessState: PersonalDataAccessState {
        let status = Self.checkMailAutomationPermission(askUserIfNeeded: false)
        Log.mail.debug("accessState: AEDeterminePermissionToAutomateTarget(askUserIfNeeded: false) returned \(status)")
        return Self.mapAutomationPermissionStatus(status)
    }

    /// Pure mapping from an `AEDeterminePermissionToAutomateTarget` status code to the shared
    /// tri-state enum, kept separate from the live AE call for testability.
    nonisolated static func mapAutomationPermissionStatus(_ status: OSStatus) -> PersonalDataAccessState {
        switch status {
        case 0: return .granted                     // noErr
        case -1743: return .denied                  // errAEEventNotPermitted
        default: return .notDetermined               // errAEEventWouldRequireUserConsent (-1744) and others
        }
    }

    /// Outcome of `requestAccess()`. `.suspectedPlatformBug` is distinct from `.denied` because a
    /// genuine user denial requires the system dialog to actually be shown and answered, which
    /// takes real human time — see `classifyRequestOutcome` for the timing heuristic that tells
    /// the two apart.
    enum MailAccessOutcome: Equatable {
        case granted
        case denied
        case suspectedPlatformBug
    }

    /// Below this, a failure almost certainly means no dialog was ever shown — no human reads,
    /// decides, and clicks a permission dialog in under this long. Generous on purpose: the goal
    /// is zero false positives (never mistake a real, if fast, denial for the platform bug), not a
    /// tight bound.
    nonisolated static let suspectedBugThreseholdSeconds: TimeInterval = 0.3

    /// Pure classification, kept separate from the live call for testability.
    nonisolated static func classifyRequestOutcome(succeeded: Bool, elapsedSeconds: TimeInterval) -> MailAccessOutcome {
        if succeeded { return .granted }
        return elapsedSeconds < suspectedBugThreseholdSeconds ? .suspectedPlatformBug : .denied
    }

    /// Triggers the real macOS "Confab wants to control Mail" Automation permission prompt if
    /// the user hasn't been asked yet (a no-op if already granted or denied).
    ///
    /// Live-verified (2026-08-19, several rounds with Rune): the prompt never appears on this
    /// machine (macOS 27 beta) for Confab specifically — not a threading bug (tried direct call,
    /// `DispatchQueue.main.async`), not the wrong API (tried `AEDeterminePermissionToAutomateTarget`
    /// AND a real Apple Event via `runHandler`, both fail identically), not a build/signing issue
    /// (verified the installed app's signature, team ID, Info.plist key, and even a notarized
    /// build — all correct), and not an OS-wide Automation outage (Terminal → Mail works fine).
    /// Every failure returns in single-digit milliseconds — far too fast for a real dialog to have
    /// been shown and answered. This matches an already-documented macOS 27 beta bug in this same
    /// project (`EKEventStore.requestFullAccessToEvents()`/`CNContactStore.requestAccess()` failing
    /// identically for Calendar/Contacts while Reminders/Location work fine) — a category of
    /// TCC-gated permission requests that this OS beta just never prompts for from third-party
    /// apps, regardless of what the app does.
    ///
    /// Given that, this still attempts the real thing first (the way it *should* work, and will
    /// once the OS bug is fixed) via the same `runHandler` path `mail_list_accounts`/
    /// `testConnection()` use, but times the attempt and treats an implausibly-fast failure as the
    /// known platform bug rather than a real denial — callers can then fall back to sending the
    /// user straight to System Settings' Automation pane instead of a dead-end "nothing happened."
    @discardableResult
    func requestAccess() async -> MailAccessOutcome {
        Log.mail.info("requestAccess: called — attempting a real Apple Event (listAccounts) to trigger the OS consent prompt")
        let start = Date()
        let result = await runHandler("listAccounts", arguments: [])
        let elapsed = Date().timeIntervalSince(start)
        switch result {
        case .success:
            Log.mail.info("requestAccess: real Apple Event succeeded after \(String(format: "%.3f", elapsed))s — access granted")
            return .granted
        case .failure(let error):
            let outcome = Self.classifyRequestOutcome(succeeded: false, elapsedSeconds: elapsed)
            Log.mail.info("requestAccess: real Apple Event failed after \(String(format: "%.3f", elapsed))s — \(String(describing: error)) — classified as \(String(describing: outcome))")
            return outcome
        }
    }

    private static func checkMailAutomationPermission(askUserIfNeeded: Bool) -> OSStatus {
        let bundleID = "com.apple.mail"
        var target = AEAddressDesc()
        let createStatus = bundleID.withCString { cString in
            AECreateDesc(typeApplicationBundleID, cString, bundleID.utf8.count, &target)
        }
        guard createStatus == 0 else { return OSStatus(createStatus) }
        defer { AEDisposeDesc(&target) }
        return AEDeterminePermissionToAutomateTarget(&target, typeWildCard, typeWildCard, askUserIfNeeded)
    }

    @ObservationIgnored
    private lazy var compiledScript: NSAppleScript? = {
        guard let script = NSAppleScript(source: Self.scriptSource) else { return nil }
        var errorDict: NSDictionary?
        guard script.compileAndReturnError(&errorDict) else {
            Log.mail.error("AppleMailService: script failed to compile: \(String(describing: errorDict))")
            return nil
        }
        return script
    }()

    // MARK: - Embedded AppleScript
    //
    // Return-value field-order contracts (Swift decoding must match these exactly):
    //   searchMessages -> list of [messageId, subject, from, dateReceived, mailbox, account, attachmentCount]
    //   getMessage     -> [subject, from, dateReceived, content, attachmentNamesJoinedByPipe]
    //                     or a single-element list {"__NOT_FOUND__"} if no match
    //   saveAttachment -> [status ("ok"/"error"), pathOnSuccessOrMessageOnError]
    //   listAccounts   -> list of [name, emailAddressesJoinedByComma]
    //
    // Arguments are always passed as real Apple Event list arguments (see `runHandler`), never
    // interpolated into this source text — user search terms (accented characters, quotes, etc.)
    // are safe by construction.
    nonisolated static let scriptSource = """
    on searchMessages(accountName, mailboxName, subjectQ, fromQ, bodyQ, sinceStr, beforeStr, maxResultsStr)
    \tset maxResults to (maxResultsStr as integer)
    \ttell application "Mail"
    \t\tset resultList to {}
    \t\tset acctList to {}
    \t\tif accountName is "" then
    \t\t\tset acctList to accounts
    \t\telse
    \t\t\trepeat with a in accounts
    \t\t\t\tset matched to false
    \t\t\t\tif (name of a) is accountName then set matched to true
    \t\t\t\tif not matched then
    \t\t\t\t\ttry
    \t\t\t\t\t\tif (email addresses of a) contains accountName then set matched to true
    \t\t\t\t\tend try
    \t\t\t\tend if
    \t\t\t\tif matched then set end of acctList to a
    \t\t\tend repeat
    \t\tend if

    \t\trepeat with acct in acctList
    \t\t\tset targetMailbox to missing value
    \t\t\ttry
    \t\t\t\tif mailboxName is "" then
    \t\t\t\t\tset targetMailbox to mailbox "INBOX" of acct
    \t\t\t\telse
    \t\t\t\t\tset targetMailbox to mailbox mailboxName of acct
    \t\t\t\tend if
    \t\t\tend try

    \t\t\tif targetMailbox is not missing value then
    \t\t\t\tset candidateMessages to {}
    \t\t\t\ttry
    \t\t\t\t\tif subjectQ is not "" then
    \t\t\t\t\t\tset candidateMessages to (messages of targetMailbox whose subject contains subjectQ)
    \t\t\t\t\telse
    \t\t\t\t\t\tset candidateMessages to messages of targetMailbox
    \t\t\t\t\tend if
    \t\t\t\tend try

    \t\t\t\trepeat with m in candidateMessages
    \t\t\t\t\tset isMatch to true
    \t\t\t\t\tif fromQ is not "" then
    \t\t\t\t\t\ttry
    \t\t\t\t\t\t\tif not ((sender of m) contains fromQ) then set isMatch to false
    \t\t\t\t\t\ton error
    \t\t\t\t\t\t\tset isMatch to false
    \t\t\t\t\t\tend try
    \t\t\t\t\tend if
    \t\t\t\t\tif isMatch and bodyQ is not "" then
    \t\t\t\t\t\ttry
    \t\t\t\t\t\t\tif not ((content of m) contains bodyQ) then set isMatch to false
    \t\t\t\t\t\ton error
    \t\t\t\t\t\t\tset isMatch to false
    \t\t\t\t\t\tend try
    \t\t\t\t\tend if
    \t\t\t\t\tif isMatch and sinceStr is not "" then
    \t\t\t\t\t\ttry
    \t\t\t\t\t\t\tif (date received of m) < (date sinceStr) then set isMatch to false
    \t\t\t\t\t\tend try
    \t\t\t\t\tend if
    \t\t\t\t\tif isMatch and beforeStr is not "" then
    \t\t\t\t\t\ttry
    \t\t\t\t\t\t\tif (date received of m) > (date beforeStr) then set isMatch to false
    \t\t\t\t\t\tend try
    \t\t\t\t\tend if

    \t\t\t\t\tif isMatch then
    \t\t\t\t\t\tset msgId to (message id of m) as string
    \t\t\t\t\t\tset msgSubject to (subject of m) as string
    \t\t\t\t\t\tset msgFrom to (sender of m) as string
    \t\t\t\t\t\tset msgDate to (date received of m) as string
    \t\t\t\t\t\tset attachCount to (count of mail attachments of m)
    \t\t\t\t\t\tset end of resultList to {msgId, msgSubject, msgFrom, msgDate, (name of targetMailbox), (name of acct), (attachCount as string)}
    \t\t\t\t\tend if

    \t\t\t\t\tif (count of resultList) ≥ maxResults then exit repeat
    \t\t\t\tend repeat
    \t\t\tend if
    \t\t\tif (count of resultList) ≥ maxResults then exit repeat
    \t\tend repeat

    \t\treturn resultList
    \tend tell
    end searchMessages

    on findMessage(msgId, accountName, mailboxName)
    \ttell application "Mail"
    \t\tset foundMsg to missing value
    \t\tset acctList to {}
    \t\tif accountName is "" then
    \t\t\tset acctList to accounts
    \t\telse
    \t\t\trepeat with a in accounts
    \t\t\t\tset matched to false
    \t\t\t\tif (name of a) is accountName then set matched to true
    \t\t\t\tif not matched then
    \t\t\t\t\ttry
    \t\t\t\t\t\tif (email addresses of a) contains accountName then set matched to true
    \t\t\t\t\tend try
    \t\t\t\tend if
    \t\t\t\tif matched then set end of acctList to a
    \t\t\tend repeat
    \t\tend if

    \t\trepeat with acct in acctList
    \t\t\ttry
    \t\t\t\tif mailboxName is "" then
    \t\t\t\t\tset mb to mailbox "INBOX" of acct
    \t\t\t\telse
    \t\t\t\t\tset mb to mailbox mailboxName of acct
    \t\t\t\tend if
    \t\t\t\tset foundMatches to (messages of mb whose message id is msgId)
    \t\t\t\tif (count of foundMatches) > 0 then
    \t\t\t\t\tset foundMsg to item 1 of foundMatches
    \t\t\t\t\texit repeat
    \t\t\t\tend if
    \t\t\tend try
    \t\tend repeat

    \t\tif foundMsg is missing value then
    \t\t\trepeat with acct in accounts
    \t\t\t\trepeat with mb in mailboxes of acct
    \t\t\t\t\ttry
    \t\t\t\t\t\tset foundMatches to (messages of mb whose message id is msgId)
    \t\t\t\t\t\tif (count of foundMatches) > 0 then
    \t\t\t\t\t\t\tset foundMsg to item 1 of foundMatches
    \t\t\t\t\t\t\texit repeat
    \t\t\t\t\t\tend if
    \t\t\t\t\tend try
    \t\t\t\tend repeat
    \t\t\t\tif foundMsg is not missing value then exit repeat
    \t\t\tend repeat
    \t\tend if

    \t\treturn foundMsg
    \tend tell
    end findMessage

    on getMessage(msgId, accountName, mailboxName)
    \ttell application "Mail"
    \t\tset foundMsg to my findMessage(msgId, accountName, mailboxName)
    \t\tif foundMsg is missing value then
    \t\t\treturn {"__NOT_FOUND__"}
    \t\tend if

    \t\tset msgSubject to (subject of foundMsg) as string
    \t\tset msgFrom to (sender of foundMsg) as string
    \t\tset msgDate to (date received of foundMsg) as string
    \t\tset msgContent to (content of foundMsg) as string
    \t\tset attachNames to {}
    \t\trepeat with att in mail attachments of foundMsg
    \t\t\tset end of attachNames to (name of att)
    \t\tend repeat
    \t\tset attachNamesStr to my joinList(attachNames, "||")

    \t\treturn {msgSubject, msgFrom, msgDate, msgContent, attachNamesStr}
    \tend tell
    end getMessage

    on saveAttachment(msgId, attachmentName, accountName, mailboxName, destPath)
    \ttell application "Mail"
    \t\tset foundMsg to my findMessage(msgId, accountName, mailboxName)
    \t\tif foundMsg is missing value then
    \t\t\treturn {"error", "message not found"}
    \t\tend if

    \t\tset targetAttachment to missing value
    \t\trepeat with att in mail attachments of foundMsg
    \t\t\tif (name of att) is attachmentName then
    \t\t\t\tset targetAttachment to att
    \t\t\t\texit repeat
    \t\t\tend if
    \t\tend repeat

    \t\tif targetAttachment is missing value then
    \t\t\treturn {"error", "attachment not found"}
    \t\tend if

    \t\ttry
    \t\t\tsave targetAttachment in (POSIX file destPath)
    \t\t\treturn {"ok", destPath}
    \t\ton error errMsg
    \t\t\treturn {"error", errMsg}
    \t\tend try
    \tend tell
    end saveAttachment

    on listAccounts()
    \ttell application "Mail"
    \t\tset resultList to {}
    \t\trepeat with acct in accounts
    \t\t\tset acctEmails to ""
    \t\t\ttry
    \t\t\t\tset acctEmails to my joinList((email addresses of acct), ", ")
    \t\t\tend try
    \t\t\tset end of resultList to {(name of acct), acctEmails}
    \t\tend repeat
    \t\treturn resultList
    \tend tell
    end listAccounts

    on joinList(theList, delim)
    \tset oldDelims to AppleScript's text item delimiters
    \tset AppleScript's text item delimiters to delim
    \tset joined to theList as string
    \tset AppleScript's text item delimiters to oldDelims
    \treturn joined
    end joinList
    """

    // MARK: - Apple Event Handler Invocation

    /// Calls a named handler in the compiled script, passing `arguments` as real Apple Event
    /// list arguments (not string-interpolated source) — safe for arbitrary user text, and the
    /// only way to invoke a specific handler since `NSAppleScript` has no `executeHandler`
    /// convenience API of its own.
    private func runHandler(_ handlerName: String, arguments: [String]) async -> Result<NSAppleEventDescriptor, AppleMailError> {
        await withCheckedContinuation { continuation in
            queue.async {
                guard let script = self.compiledScript else {
                    continuation.resume(returning: .failure(.scriptNotCompiled))
                    return
                }

                let argsListDescriptor = NSAppleEventDescriptor.list()
                for (index, arg) in arguments.enumerated() {
                    argsListDescriptor.insert(NSAppleEventDescriptor(string: arg), at: index + 1)
                }

                let event = NSAppleEventDescriptor(
                    eventClass: AEEventClass(kASAppleScriptSuite),
                    eventID: AEEventID(kASSubroutineEvent),
                    targetDescriptor: nil,
                    returnID: AEReturnID(kAutoGenerateReturnID),
                    transactionID: AETransactionID(kAnyTransactionID)
                )
                event.setParam(NSAppleEventDescriptor(string: handlerName), forKeyword: AEKeyword(keyASSubroutineName))
                event.setParam(argsListDescriptor, forKeyword: AEKeyword(keyDirectObject))

                var errorDict: NSDictionary?
                let result = script.executeAppleEvent(event, error: &errorDict)
                if let errorDict {
                    let number = (errorDict[NSAppleScript.errorNumber] as? Int) ?? 0
                    let message = (errorDict[NSAppleScript.errorMessage] as? String) ?? "Unknown error"
                    continuation.resume(returning: .failure(.appleScriptError(number: number, message: message)))
                } else {
                    continuation.resume(returning: .success(result))
                }
            }
        }
    }

    // MARK: - Descriptor Decoding (pure, testable — no live Apple Events involved)

    nonisolated static func decodeStringList(_ descriptor: NSAppleEventDescriptor) -> [String] {
        guard descriptor.numberOfItems > 0 else {
            if let value = descriptor.stringValue { return [value] }
            return []
        }
        var result: [String] = []
        for i in 1...descriptor.numberOfItems {
            result.append(descriptor.atIndex(i)?.stringValue ?? "")
        }
        return result
    }

    nonisolated static func parseSearchResults(from descriptor: NSAppleEventDescriptor) -> [MailSearchResult] {
        guard descriptor.numberOfItems > 0 else { return [] }
        var results: [MailSearchResult] = []
        for i in 1...descriptor.numberOfItems {
            guard let itemDescriptor = descriptor.atIndex(i) else { continue }
            let fields = decodeStringList(itemDescriptor)
            guard fields.count >= 7 else { continue }
            results.append(MailSearchResult(
                messageId: fields[0], subject: fields[1], from: fields[2],
                dateReceived: fields[3], mailbox: fields[4], account: fields[5],
                attachmentCount: fields[6]
            ))
        }
        return results
    }

    nonisolated static func mapAppleScriptError(number: Int, message: String) -> String {
        switch number {
        case -1743:
            return "Confab is not authorized to control Mail.app. Grant access in System Settings \u{2192} Privacy & Security \u{2192} Automation \u{2192} Confab \u{2192} Mail, then try again."
        case -600, -609:
            return "Mail.app is not available on this Mac."
        default:
            return "Mail.app error \(number): \(message)"
        }
    }

    nonisolated static func errorResult(_ error: AppleMailError) -> [String: Any] {
        switch error {
        case .scriptNotCompiled:
            return ["error": "Mail integration failed to initialize (AppleScript compile error)."]
        case .appleScriptError(let number, let message):
            return ["error": mapAppleScriptError(number: number, message: message)]
        }
    }

    // MARK: - Tool-Backing Methods

    private func listAccountsResult() async -> [String: Any] {
        switch await runHandler("listAccounts", arguments: []) {
        case .success(let descriptor):
            var accounts: [[String: String]] = []
            if descriptor.numberOfItems > 0 {
                for i in 1...descriptor.numberOfItems {
                    guard let item = descriptor.atIndex(i) else { continue }
                    let fields = Self.decodeStringList(item)
                    guard fields.count >= 2 else { continue }
                    accounts.append(["name": fields[0], "emails": fields[1]])
                }
            }
            return ["count": accounts.count, "accounts": accounts]
        case .failure(let error):
            return Self.errorResult(error)
        }
    }

    /// True if at least one search criterion is non-empty — an unscoped "return everything"
    /// search across all mail is too broad to allow. Pure logic, kept separate for testability.
    nonisolated static func hasSearchCriteria(subjectContains: String?, fromContains: String?, bodyContains: String?, since: String?, before: String?) -> Bool {
        [subjectContains, fromContains, bodyContains, since, before]
            .contains { ($0?.isEmpty ?? true) == false }
    }

    private func searchMessages(
        account: String?, mailbox: String?, subjectContains: String?,
        fromContains: String?, bodyContains: String?, since: String?, before: String?
    ) async -> [String: Any] {
        guard Self.hasSearchCriteria(subjectContains: subjectContains, fromContains: fromContains, bodyContains: bodyContains, since: since, before: before) else {
            return ["error": "At least one of subject_contains, from_contains, body_contains, since, or before is required — an unscoped search across all mail is too broad."]
        }

        let args = [
            account ?? "", mailbox ?? "INBOX",
            subjectContains ?? "", fromContains ?? "", bodyContains ?? "",
            since ?? "", before ?? "", "100"
        ]
        switch await runHandler("searchMessages", arguments: args) {
        case .success(let descriptor):
            let results = Self.parseSearchResults(from: descriptor)
            let messages = results.map { r -> [String: Any] in
                [
                    "message_id": r.messageId,
                    "subject": r.subject,
                    "from": r.from,
                    "date_received": r.dateReceived,
                    "mailbox": r.mailbox,
                    "account": r.account,
                    "has_attachments": (Int(r.attachmentCount) ?? 0) > 0
                ]
            }
            return ["count": messages.count, "messages": messages]
        case .failure(let error):
            return Self.errorResult(error)
        }
    }

    private func getMessage(messageId: String, account: String?, mailbox: String?) async -> [String: Any] {
        let args = [messageId, account ?? "", mailbox ?? "INBOX"]
        switch await runHandler("getMessage", arguments: args) {
        case .success(let descriptor):
            let fields = Self.decodeStringList(descriptor)
            if fields.count == 1 && fields[0] == "__NOT_FOUND__" {
                return ["error": "No message found with id '\(messageId)'"]
            }
            guard fields.count >= 5 else {
                return ["error": "Unexpected response from Mail.app"]
            }
            let content = fields[3]
            let truncatedContent = content.utf8.count > 20_000
                ? String(content.prefix(20_000)) + "\n... (truncated)"
                : content
            let attachmentNames = fields[4].isEmpty ? [] : fields[4].components(separatedBy: "||")
            return [
                "subject": fields[0],
                "from": fields[1],
                "date_received": fields[2],
                "content": truncatedContent,
                "attachments": attachmentNames.map { ["name": $0] }
            ]
        case .failure(let error):
            return Self.errorResult(error)
        }
    }

    private func saveAttachment(messageId: String, attachmentName: String, account: String?, mailbox: String?, destPath: String) async -> [String: Any] {
        let resolvedDest = ((destPath as NSString).expandingTildeInPath as NSString).standardizingPath
        guard MCPService.shared.isPathAllowed(resolvedDest) else {
            return ["error": "dest_path is outside allowed folders. Configured folders: \(MCPService.shared.allowedFolders.joined(separator: ", "))"]
        }

        let args = [messageId, attachmentName, account ?? "", mailbox ?? "INBOX", resolvedDest]
        switch await runHandler("saveAttachment", arguments: args) {
        case .success(let descriptor):
            let fields = Self.decodeStringList(descriptor)
            guard fields.count >= 2 else { return ["error": "Unexpected response from Mail.app"] }
            guard fields[0] == "ok" else { return ["error": fields[1]] }

            var size: Int? = nil
            if let attrs = try? FileManager.default.attributesOfItem(atPath: resolvedDest) {
                size = attrs[.size] as? Int
            }
            var result: [String: Any] = ["success": true, "path": resolvedDest]
            if let size { result["size"] = size }
            return result
        case .failure(let error):
            return Self.errorResult(error)
        }
    }

    // MARK: - Tool Schemas & Dispatch

    func getToolSchemas() -> [Tool] {
        [
            makeTool(
                name: "mail_list_accounts",
                description: "List the Mail.app accounts configured on this Mac (name and email addresses). Requires user approval. Use this to find the right value for the 'account' parameter on other mail tools.",
                properties: [:],
                required: []
            ),
            makeTool(
                name: "mail_search",
                description: "Search Apple Mail for messages. Requires user approval. Pass 'account' (account name or email address) when the user names a specific account — searching all accounts is significantly slower. Defaults to the INBOX; pass 'mailbox' to search a different folder (e.g. 'Sent', 'Archive'). At least one of subject_contains/from_contains/body_contains/since/before is required.",
                properties: [
                    "account": prop("string", "Optional: account name or email address to scope the search to (omit to search all accounts)"),
                    "mailbox": prop("string", "Optional: mailbox/folder name (default: INBOX)"),
                    "subject_contains": prop("string", "Optional: text the subject must contain"),
                    "from_contains": prop("string", "Optional: text the sender must contain"),
                    "body_contains": prop("string", "Optional: text the message body must contain (slower — opens each candidate message)"),
                    "since": prop("string", "Optional: only messages received on/after this date"),
                    "before": prop("string", "Optional: only messages received before this date")
                ],
                required: []
            ),
            makeTool(
                name: "mail_get_message",
                description: "Read a specific Mail message's full content and attachment list. Requires user approval. Pass the 'account'/'mailbox' values returned alongside this message by mail_search for a faster lookup — omitting them triggers a slower unscoped scan across all accounts.",
                properties: [
                    "message_id": prop("string", "The message's id, from mail_search"),
                    "account": prop("string", "Optional: account hint from mail_search (speeds up the lookup)"),
                    "mailbox": prop("string", "Optional: mailbox hint from mail_search (speeds up the lookup)")
                ],
                required: ["message_id"]
            ),
            makeTool(
                name: "mail_save_attachment",
                description: "Save an attachment from a Mail message to a local file path (must be inside an allowed MCP folder). Requires user approval. Use the saved path with read_file (e.g. for PDF text) or another tool such as paperless_upload_document.",
                properties: [
                    "message_id": prop("string", "The message's id, from mail_search or mail_get_message"),
                    "attachment_name": prop("string", "The attachment's filename, from mail_get_message"),
                    "dest_path": prop("string", "Absolute local path to save the attachment to (must be inside an allowed folder)"),
                    "account": prop("string", "Optional: account hint (speeds up the lookup)"),
                    "mailbox": prop("string", "Optional: mailbox hint (speeds up the lookup)")
                ],
                required: ["message_id", "attachment_name", "dest_path"]
            )
        ]
    }

    func executeTool(name: String, arguments: String) async -> [String: Any] {
        Log.mail.info("Executing mail tool: \(name)")
        let args = Self.parseArgs(arguments)

        switch name {
        case "mail_list_accounts":
            return await listAccountsResult()

        case "mail_search":
            return await searchMessages(
                account: args["account"] as? String,
                mailbox: args["mailbox"] as? String,
                subjectContains: args["subject_contains"] as? String,
                fromContains: args["from_contains"] as? String,
                bodyContains: args["body_contains"] as? String,
                since: args["since"] as? String,
                before: args["before"] as? String
            )

        case "mail_get_message":
            guard let messageId = args["message_id"] as? String, !messageId.isEmpty else {
                return ["error": "Missing required parameter: message_id"]
            }
            return await getMessage(messageId: messageId, account: args["account"] as? String, mailbox: args["mailbox"] as? String)

        case "mail_save_attachment":
            guard let messageId = args["message_id"] as? String, !messageId.isEmpty,
                  let attachmentName = args["attachment_name"] as? String, !attachmentName.isEmpty,
                  let destPath = args["dest_path"] as? String, !destPath.isEmpty else {
                return ["error": "Missing required parameter(s): message_id, attachment_name, dest_path"]
            }
            return await saveAttachment(messageId: messageId, attachmentName: attachmentName, account: args["account"] as? String, mailbox: args["mailbox"] as? String, destPath: destPath)

        default:
            return ["error": "Unknown Mail tool: \(name)"]
        }
    }

    // MARK: - Approval Summary

    func approvalSummary(forTool name: String, arguments: String) -> String {
        let args = Self.parseArgs(arguments)
        switch name {
        case "mail_list_accounts":
            return "List your Mail.app accounts"
        case "mail_search":
            var parts: [String] = []
            if let account = args["account"] as? String, !account.isEmpty { parts.append("in \(account)") }
            if let subject = args["subject_contains"] as? String, !subject.isEmpty { parts.append("subject contains \"\(subject)\"") }
            if let from = args["from_contains"] as? String, !from.isEmpty { parts.append("from contains \"\(from)\"") }
            if let body = args["body_contains"] as? String, !body.isEmpty { parts.append("body contains \"\(body)\"") }
            let scope = parts.isEmpty ? "" : " (\(parts.joined(separator: ", ")))"
            return "Search Mail\(scope)"
        case "mail_get_message":
            let id = (args["message_id"] as? String) ?? ""
            return "Read a Mail message (id: \(id.prefix(40))\(id.count > 40 ? "…" : ""))"
        case "mail_save_attachment":
            let name = (args["attachment_name"] as? String) ?? "attachment"
            let dest = (args["dest_path"] as? String) ?? ""
            return "Save Mail attachment \"\(name)\" to \(dest)"
        default:
            return "Perform action: \(name)"
        }
    }

    // MARK: - Connection Test (Settings UI)

    func testConnection() async -> Result<String, Error> {
        switch await runHandler("listAccounts", arguments: []) {
        case .success(let descriptor):
            let count = descriptor.numberOfItems
            var names: [String] = []
            if count > 0 {
                for i in 1...count {
                    if let item = descriptor.atIndex(i) {
                        let fields = Self.decodeStringList(item)
                        if let first = fields.first { names.append(first) }
                    }
                }
            }
            let namesSuffix = names.isEmpty ? "" : ": \(names.joined(separator: ", "))"
            return .success("Connected \u{2014} found \(count) account\(count == 1 ? "" : "s")\(namesSuffix)")
        case .failure(let error):
            let message: String
            switch error {
            case .scriptNotCompiled:
                message = "Mail integration failed to initialize (AppleScript compile error)."
            case .appleScriptError(let number, let msg):
                message = Self.mapAppleScriptError(number: number, message: msg)
            }
            return .failure(NSError(domain: "AppleMailService", code: 1, userInfo: [NSLocalizedDescriptionKey: message]))
        }
    }

    // MARK: - Helpers

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

    nonisolated static func parseArgs(_ arguments: String) -> [String: Any] {
        guard let data = arguments.data(using: .utf8),
              let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            return [:]
        }
        return dict
    }
}
