/*
 This file implements the native service layer: local configuration storage,
 System Settings launch, provider probing/drafting, Messages database reads/sends,
 queue persistence, and the engine facade used by the dashboard client.

 First-level inventory:
 NativeServiceError
 NativeStorageService
 NativeSystemSettings
 NativeProviderService
 Optional<String> extension
 nativeCloudText
 NativeMessagesService
 NativeQueueService
 NativeEngineService

 NativeServiceError gives failure categories. Storage and settings handle local
 state and permissions; provider, Messages, and queue services own integrations.
 NativeEngineService composes them into the app's status/current/send/pause API.
*/
import AppKit
import Foundation
import SQLite3

/// User-facing errors shared by storage, provider, Messages, queue, and engine services.
/// Localized descriptions are intentionally actionable because they reach the dashboard.
enum NativeServiceError: LocalizedError {
    case invalidProviderURL
    case databaseUnavailable(String)
    case providerUnavailable(String)
    case sendFailed(String)

    var errorDescription: String? {
        switch self {
        case .invalidProviderURL: return "The provider URL is not allowed."
        case .databaseUnavailable(let message): return message
        case .providerUnavailable(let message): return message
        case .sendFailed(let message): return message
        }
    }
}

/// Native owner of the local configuration file. Settings never leave the
/// app unless a user-selected provider needs them for a request.
/// Reads and atomically writes iML's local JSON configuration.
/// The service creates parent directories and replaces files through a temporary path.
final class NativeStorageService {
    let configURL: URL

    init(configURL: URL = FileManager.default.homeDirectoryForCurrentUser
        .appendingPathComponent(".imessage-agent/locker_config.json")) {
        self.configURL = configURL
    }

    func load() -> [String: Any] {
        guard let data = try? Data(contentsOf: configURL),
              let value = try? JSONSerialization.jsonObject(with: data),
              let object = value as? [String: Any] else { return [:] }
        return object
    }

    func save(_ values: [String: Any]) throws {
        let directory = configURL.deletingLastPathComponent()
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        let data = try JSONSerialization.data(withJSONObject: values, options: [.prettyPrinted, .sortedKeys])
        let temporary = directory.appendingPathComponent(".locker_config.json.tmp")
        try data.write(to: temporary, options: .atomic)
        if FileManager.default.fileExists(atPath: configURL.path) {
            _ = try FileManager.default.replaceItemAt(configURL, withItemAt: temporary)
        } else {
            try FileManager.default.moveItem(at: temporary, to: configURL)
        }
        try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configURL.path)
    }
}

/// The Full Disk Access pane, by whichever URL this macOS answers.
///
/// Both the dashboard and the menu-bar launcher send the user here, so the list
/// of destinations lives in one place rather than being duplicated with a chance
/// of drifting apart.
/// Opens the macOS privacy/settings destinations required by onboarding.
/// It tries the precise deep link first and falls back to a general settings page.
enum NativeSystemSettings {
    static func openFullDiskAccess() {
        let destinations = [
            "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
            "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
        ].compactMap(URL.init(string:))
        for destination in destinations where NSWorkspace.shared.open(destination) {
            return
        }
        NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app"))
    }
}

/// Provider transport owned by the native process. It performs health checks
/// without sending message content; draft generation can move to this same
/// client once the queue worker is switched over.
/// Probes configured local/self-hosted providers and generates reply drafts.
/// Results are cached briefly by configuration while network work remains asynchronous.
final class NativeProviderService {
    private let session: URLSession

    /// Last probe result, and what it was a probe of. Keyed on the whole
    /// configuration so changing the model or the URL in Settings invalidates it
    /// rather than reporting the previous endpoint's health.
    private var cachedReach: (key: String, reach: NativeProviderReach)?
    private var lastProbeAt: Date?
    private var probing = false
    private let reachLock = NSLock()
    private let staleAfter: TimeInterval = 10

    init(session: URLSession = .shared) { self.session = session }

    /// Synchronous, because status is read on every refresh and must not block.
    /// The answer is the last probe's; a stale one triggers a fresh probe in the
    /// background and is returned next time.
    func reach(for configuration: NativeProviderConfigurationRequest) -> NativeProviderReach {
        if let missing = NativeProviderReach.missingConfiguration(configuration) {
            reachLock.lock()
            cachedReach = nil
            reachLock.unlock()
            return .unconfigured(missing)
        }

        // Cloud is not probed. See NativeProviderReach.assumed.
        if configuration.provider == "cloud" {
            return .assumed(configuration.model.isEmpty ? "Cloud" : configuration.model)
        }

        let key = Self.key(for: configuration)
        reachLock.lock()
        let known = cachedReach?.key == key ? cachedReach?.reach : nil
        let stale = cachedReach?.key != key
            || lastProbeAt.map { Date().timeIntervalSince($0) > staleAfter } ?? true
        let shouldProbe = stale && !probing
        if shouldProbe { probing = true }
        reachLock.unlock()

        if shouldProbe { probe(configuration, key: key) }
        return known ?? .checking
    }

    private func probe(_ configuration: NativeProviderConfigurationRequest, key: String) {
        func finish(_ reach: NativeProviderReach) {
            reachLock.lock()
            cachedReach = (key, reach)
            lastProbeAt = Date()
            probing = false
            reachLock.unlock()
            NotificationCenter.default.post(name: .imlNativeStatusDidChange, object: nil)
        }

        guard let endpoint = try? validatedURL(configuration.url, provider: configuration.provider) else {
            finish(.unconfigured("The provider URL is not valid for this provider."))
            return
        }
        var request = URLRequest(url: endpoint.appendingPathComponent("api/tags"))
        request.httpMethod = "GET"
        request.timeoutInterval = 5
        session.dataTask(with: request) { data, response, error in
            if let error {
                finish(.unreachable("Nothing answered at \(endpoint.absoluteString): \(error.localizedDescription)."))
                return
            }
            let code = (response as? HTTPURLResponse)?.statusCode ?? 0
            guard (200..<300).contains(code) else {
                finish(.unreachable("\(endpoint.absoluteString) returned HTTP \(code)."))
                return
            }
            let installed = NativeProviderReach.modelNames(from: data)
            guard !configuration.model.isEmpty else {
                finish(.ready("Connected"))
                return
            }
            // A model the provider does not have is the failure that used to
            // arrive as an empty draft, so it is checked rather than assumed
            // from a parseable response.
            if NativeProviderReach.contains(configuration.model, in: installed) {
                finish(.ready(configuration.model))
            } else {
                finish(.modelMissing(model: configuration.model, available: installed))
            }
        }.resume()
    }

    private static func key(for configuration: NativeProviderConfigurationRequest) -> String {
        [configuration.provider, configuration.url, configuration.model].joined(separator: "|")
    }

    func check(_ configuration: NativeProviderConfigurationRequest,
               completion: @escaping (Result<NativeProviderStatus, Error>) -> Void) {
        do {
            let endpoint = try validatedURL(configuration.url, provider: configuration.provider)
            var request = URLRequest(url: endpoint.appendingPathComponent("api/tags"))
            request.httpMethod = "GET"
            request.timeoutInterval = 5
            session.dataTask(with: request) { data, response, error in
                if let error { completion(.failure(NativeServiceError.providerUnavailable(error.localizedDescription))); return }
                let status = (response as? HTTPURLResponse)?.statusCode ?? 0
                guard (200..<300).contains(status) else {
                    completion(.failure(NativeServiceError.providerUnavailable("Provider returned HTTP \(status).")))
                    return
                }
                let hasModel = configuration.model.isEmpty || data.flatMap { try? JSONSerialization.jsonObject(with: $0) } != nil
                completion(.success(NativeProviderStatus(provider: configuration.provider, ready: hasModel, label: configuration.model.isEmpty ? "Connected" : configuration.model, detail: "Native provider connection verified.")))
            }.resume()
        } catch { completion(.failure(error)) }
    }

    func generateDraft(for message: NativeMessage,
                       configuration: NativeProviderConfigurationRequest,
                       persona: String,
                       currentDraft: String = "",
                       context: [NativeContextMessage] = [],
                       completion: @escaping (Result<String, Error>) -> Void) {
        do {
            let endpoint = try validatedURL(configuration.url, provider: configuration.provider)
            let isCloud = configuration.provider == "cloud"
            guard !isCloud || !configuration.apiKey.isEmpty else {
                completion(.failure(NativeServiceError.providerUnavailable("Cloud provider credentials are not configured.")))
                return
            }
            var request = URLRequest(url: isCloud ? endpoint : endpoint.appendingPathComponent("api/chat"))
            request.httpMethod = "POST"
            request.timeoutInterval = 120
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            if isCloud { request.setValue("Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization") }
            let instructions = persona.isEmpty ? "" : "Write in this voice: \(persona)\n"
            let prior = currentDraft.isEmpty ? "" : "\nCurrent draft to improve: \(currentDraft)"
            // Depth 8. 772501a measured depth 4 inventing what depth 8 does
            // not - a cinema showing absent from the thread, a reversal of who
            // was paying, a call time contradicting the one just agreed - at
            // 0.69 agreement against a 0.85 rule. The context keeps the model
            // on facts that are in the conversation.
            let lines = context.map { "\($0.isFromMe ? "Me:" : "Them:") \($0.text)" }
            let conversation = lines.isEmpty ? ""
                : "\nRecent conversation context, oldest first:\n" + lines.joined(separator: "\n") + "\n"
            let prompt = "\(instructions)Reply naturally to this incoming message. Return only the reply text, with no preamble.\(prior)\(conversation)\nIncoming message from \(message.handle):\n\(message.text)"
            let temperature = NativeDraftTuning.temperature()
            request.httpBody = try JSONSerialization.data(withJSONObject: isCloud ? [
                "model": configuration.model,
                "input": prompt,
                "temperature": temperature,
            ] : [
                "model": configuration.model,
                "messages": [["role": "user", "content": prompt]],
                "stream": false,
                // Ollama takes sampling settings under `options`; a
                // top-level "temperature" is accepted and ignored, which is
                // how this would look fixed while still running at 0.8.
                "options": ["temperature": temperature],
            ])
            session.dataTask(with: request) { data, response, error in
                if let error { completion(.failure(NativeServiceError.providerUnavailable(error.localizedDescription))); return }
                let status = (response as? HTTPURLResponse)?.statusCode ?? 0
                guard (200..<300).contains(status), let data else {
                    completion(.failure(NativeServiceError.providerUnavailable("Provider returned HTTP \(status).")))
                    return
                }
                guard let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                      let content = isCloud ? nativeCloudText(payload) : (payload["message"] as? [String: Any])?["content"] as? String,
                      !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
                    completion(.failure(NativeServiceError.providerUnavailable("Provider returned no draft text.")))
                    return
                }
                completion(.success(content.trimmingCharacters(in: .whitespacesAndNewlines)))
            }.resume()
        } catch { completion(.failure(error)) }
    }

    private func validatedURL(_ value: String, provider: String) throws -> URL {
        guard let url = URL(string: value), let scheme = url.scheme?.lowercased(),
              (scheme == "http" || scheme == "https"), !url.host().isNilOrEmpty else {
            throw NativeServiceError.invalidProviderURL
        }
        if provider == "on_device" {
            let host = url.host?.lowercased() ?? ""
            guard host == "127.0.0.1" || host == "localhost" || host == "::1" else {
                throw NativeServiceError.invalidProviderURL
            }
        } else if scheme != "https" && url.host?.lowercased() != "localhost" && url.host?.lowercased() != "127.0.0.1" {
            throw NativeServiceError.invalidProviderURL
        }
        return url
    }
}

/// Adds the string-specific optional helper used by provider validation.
private extension Optional where Wrapped == String {
    var isNilOrEmpty: Bool { self?.isEmpty ?? true }
}

/// Extracts text from the supported cloud response shapes.
/// Returning nil lets the provider service report an actionable empty-response error.
private func nativeCloudText(_ payload: [String: Any]) -> String? {
    if let text = payload["output_text"] as? String { return text }
    guard let output = payload["output"] as? [[String: Any]] else { return nil }
    for item in output {
        if let content = item["content"] as? [[String: Any]],
           let text = content.compactMap({ $0["text"] as? String }).first { return text }
    }
    return nil
}

/// Reads conversation data from chat.db and sends explicitly through Messages.
/// It owns database access, context lookup, AppleScript sending, and Messages launching.
final class NativeMessagesService {
    let databaseURL: URL

    init(databaseURL: URL = FileManager.default.homeDirectoryForCurrentUser
        .appendingPathComponent("Library/Messages/chat.db")) {
        self.databaseURL = databaseURL
    }

    var isDatabaseReadable: Bool {
        var handle: OpaquePointer?
        let result = sqlite3_open_v2(databaseURL.path, &handle, SQLITE_OPEN_READONLY | SQLITE_OPEN_PRIVATECACHE, nil)
        defer { if let handle { sqlite3_close(handle) } }
        return result == SQLITE_OK
    }

    func latestMessages(limit: Int = 50) throws -> [NativeMessage] {
        try queryMessages(afterRowID: nil, rowIDs: nil, limit: limit)
    }

    /// Reads a bounded batch after the queue's high-water mark. The first read
    /// uses the newest rows; later reads move forward in row order so a busy
    /// Mac catches up over several refreshes instead of silently skipping the
    /// middle of a backlog.
    func incomingMessages(afterRowID: Int? = nil, limit: Int = 50) throws -> [NativeMessage] {
        try queryMessages(afterRowID: afterRowID, rowIDs: nil, limit: limit)
    }

    /// Re-reads queued rows even when their row IDs are older than the normal
    /// catch-up window. Messages edits a row in place, so correctness requires
    /// checking the rows iML still holds rather than only looking for new rows.
    func messages(forRowIDs rowIDs: Set<Int>) throws -> [NativeMessage] {
        let positive = rowIDs.filter { $0 > 0 }
        guard !positive.isEmpty else { return [] }
        return try queryMessages(afterRowID: nil, rowIDs: Array(positive), limit: positive.count)
    }

    private func queryMessages(afterRowID: Int?, rowIDs: [Int]?, limit: Int) throws -> [NativeMessage] {
        var handle: OpaquePointer?
        guard sqlite3_open_v2(databaseURL.path, &handle, SQLITE_OPEN_READONLY | SQLITE_OPEN_PRIVATECACHE, nil) == SQLITE_OK,
              let handle else { throw NativeServiceError.databaseUnavailable("Messages database is not readable.") }
        defer { sqlite3_close(handle) }

        let rowIDClause: String
        if let rowIDs, !rowIDs.isEmpty {
            rowIDClause = "AND message.ROWID IN (" + Array(repeating: "?", count: rowIDs.count).joined(separator: ",") + ")"
        } else if let afterRowID {
            rowIDClause = "AND message.ROWID > \(afterRowID)"
        } else {
            rowIDClause = ""
        }
        let ordering = afterRowID == nil ? "message.ROWID DESC" : "message.ROWID ASC"
        let sql = """
        SELECT message.ROWID, COALESCE(handle.id, ''), COALESCE(message.text, ''), message.date,
               COALESCE(message.service, ''), COALESCE(chat.chat_identifier, ''),
               message.attributedBody
        FROM message
        LEFT JOIN handle ON handle.ROWID = message.handle_id
        LEFT JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
        LEFT JOIN chat ON chat.ROWID = chat_message_join.chat_id
        WHERE message.is_from_me = 0
          -- Content lives in either column and nothing about a message
          -- predicts which. Reading only `text` made whole threads invisible.
          AND (
            (message.text IS NOT NULL AND message.text != '')
            OR message.attributedBody IS NOT NULL
          )
          -- A run of 'Liked "..."' lines is noise, not a turn to reply to.
          AND COALESCE(message.associated_message_type, 0) = 0
          AND COALESCE(message.is_service_message, 0) = 0
          AND COALESCE(message.is_audio_message, 0) = 0
          AND COALESCE(message.is_expirable, 0) = 0
          AND COALESCE(message.is_spam, 0) = 0
          AND message.date_retracted IS NULL
          \(rowIDClause)
        ORDER BY \(ordering) LIMIT ?;
        """
        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK,
              let statement else { throw NativeServiceError.databaseUnavailable("Messages schema could not be read.") }
        defer { sqlite3_finalize(statement) }
        var nextBinding: Int32 = 1
        if let rowIDs, !rowIDs.isEmpty {
            for rowID in rowIDs {
                sqlite3_bind_int64(statement, nextBinding, Int64(rowID))
                nextBinding += 1
            }
        } else if let afterRowID {
            sqlite3_bind_int64(statement, nextBinding, Int64(afterRowID))
            nextBinding += 1
        }
        sqlite3_bind_int(statement, nextBinding, Int32(max(1, min(limit, 500))))

        var result: [NativeMessage] = []
        while sqlite3_step(statement) == SQLITE_ROW {
            let rowid = Int(sqlite3_column_int64(statement, 0))
            let handle = Self.columnString(statement, index: 1)
            let raw = NativeMessageContent.content(
                text: Self.columnString(statement, index: 2),
                attributedBody: Self.columnBlob(statement, index: 6)
            )
            let split = NativeMessageContent.splitAttachmentMarker(raw)
            let text = split.caption
            guard !text.isEmpty else {
                NativeIngestionTally.record(split.hasAttachment ? "attachment_without_caption" : "unreadable_attributed_body")
                continue
            }
            let date = sqlite3_column_double(statement, 3)
            let service = Self.columnString(statement, index: 4)
            let chatGuid = Self.columnString(statement, index: 5)
            result.append(NativeMessage(rowid: rowid, handle: handle, text: text, when: Self.appleDate(date), service: service, chatGuid: chatGuid))
        }
        return result
    }

    /// Recent messages before `message` in the same thread, oldest first.
    ///
    /// Ported from messages_db.fetch_conversation_context, deleted in 81028a8.
    /// Without it every draft was generated at depth 0 — strictly less grounding
    /// than the depth-4 configuration that 772501a measured inventing a cinema
    /// showing, a payment reversal, and a contradicted call time.
    ///
    /// Nothing here is persisted. The result goes to the prompt and the review
    /// pane and is rebuilt on demand, so iML never accumulates a second copy of
    /// a conversation outside chat.db.
    func conversationContext(for message: NativeMessage, limit: Int = 8) throws -> [NativeContextMessage] {
        guard message.rowid > 0, limit > 0 else { return [] }
        let chatGuid = (message.chatGuid ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        let handleID = message.handle.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !chatGuid.isEmpty || !handleID.isEmpty else { return [] }

        var db: OpaquePointer?
        guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_PRIVATECACHE, nil) == SQLITE_OK,
              let db else { throw NativeServiceError.databaseUnavailable("Messages database is not readable.") }
        defer { sqlite3_close(db) }

        let boundary = chatGuid.isEmpty ? "handle.id = ?" : "chat.guid = ?"
        let identity = chatGuid.isEmpty ? handleID : chatGuid
        let sql = """
        SELECT COALESCE(handle.id, ''), COALESCE(message.text, ''),
               COALESCE(message.is_from_me, 0), message.date,
               message.attributedBody
        FROM message
        LEFT JOIN handle ON handle.ROWID = message.handle_id
        LEFT JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
        LEFT JOIN chat ON chat.ROWID = chat_message_join.chat_id
        WHERE \(boundary)
          -- Earlier in time, not earlier by rowid. A thread restored from
          -- backup or synced from iCloud gets its rows written in an order
          -- that has nothing to do with when the messages were sent: on this
          -- Mac a message dated the 25th appeared as "context" for one dated
          -- the 22nd. Feeding the model a conversation's future and calling it
          -- history is worse than feeding it none. Time first, rowid only to
          -- break a tie, because two messages can share a timestamp and the
          -- write order is then the best evidence of which came first.
          AND (
            message.date < (SELECT date FROM message WHERE ROWID = ?)
            OR (
              message.date = (SELECT date FROM message WHERE ROWID = ?)
              AND message.ROWID < ?
            )
          )
          -- Same two-column rule as ingestion, so a message is not surfaced
          -- while its own thread is left full of holes.
          AND (
            (message.text IS NOT NULL AND message.text != '')
            OR message.attributedBody IS NOT NULL
          )
          -- A run of 'Liked "..."' lines is noise that crowds out real turns.
          AND COALESCE(message.associated_message_type, 0) = 0
        ORDER BY message.date DESC, message.ROWID DESC
        LIMIT ?;
        """
        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK,
              let statement else { throw NativeServiceError.databaseUnavailable("Messages schema could not be read.") }
        defer { sqlite3_finalize(statement) }

        sqlite3_bind_text(statement, 1, (identity as NSString).utf8String, -1, nil)
        sqlite3_bind_int64(statement, 2, Int64(message.rowid))
        sqlite3_bind_int64(statement, 3, Int64(message.rowid))
        sqlite3_bind_int64(statement, 4, Int64(message.rowid))
        sqlite3_bind_int(statement, 5, Int32(max(1, min(limit, 50))))

        var rows: [NativeContextMessage] = []
        while sqlite3_step(statement) == SQLITE_ROW {
            let raw = NativeMessageContent.content(
                text: Self.columnString(statement, index: 1),
                attributedBody: Self.columnBlob(statement, index: 4)
            )
            // Strip the marker here too: an unreplaced U+FFFC in a context line
            // is an invisible token the model has to guess the meaning of.
            let (text, _) = NativeMessageContent.splitAttachmentMarker(raw)
            guard !text.isEmpty else { continue }
            rows.append(NativeContextMessage(
                direction: sqlite3_column_int(statement, 2) == 1 ? "me" : "them",
                handle: Self.columnString(statement, index: 0),
                text: text,
                when: Self.appleDate(sqlite3_column_double(statement, 3)) ?? ""
            ))
        }
        return rows.reversed()
    }

    func send(body: String, to handle: String) throws {
        guard !body.isEmpty, !handle.isEmpty else { throw NativeServiceError.sendFailed("A recipient and message are required.") }
        let script = "tell application \"Messages\" to send \"\(Self.appleScriptEscaped(body))\" to buddy \"\(Self.appleScriptEscaped(handle))\""
        var error: NSDictionary?
        guard let appleScript = NSAppleScript(source: script) else {
            throw NativeServiceError.sendFailed("Messages could not prepare the send request.")
        }
        appleScript.executeAndReturnError(&error)
        guard error == nil else {
            throw NativeServiceError.sendFailed((error?[NSAppleScript.errorMessage] as? String) ?? "Messages rejected the send request.")
        }
    }

    func openMessages() { NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Messages.app")) }

    private static func appleDate(_ value: Double) -> String? {
        guard value != 0 else { return nil }
        let date = Date(timeIntervalSinceReferenceDate: value / 1_000_000_000)
        return ISO8601DateFormatter().string(from: date)
    }

    private static func columnBlob(_ statement: OpaquePointer?, index: Int32) -> Data? {
        guard let bytes = sqlite3_column_blob(statement, index) else { return nil }
        let count = Int(sqlite3_column_bytes(statement, index))
        guard count > 0 else { return nil }
        return Data(bytes: bytes, count: count)
    }

    private static func columnString(_ statement: OpaquePointer?, index: Int32) -> String {
        guard let value = sqlite3_column_text(statement, index) else { return "" }
        return String(cString: value)
    }

    private static func appleScriptEscaped(_ value: String) -> String {
        value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
    }

}

/// Persists queued/current messages and groups them for dashboard consumption.
/// It also handles selection, held rows, ingestion, draft updates, skipping, and purging.
final class NativeQueueService {
    private struct PersistedState: Decodable {
        var current: NativeCurrent?
        var pending: [NativeCurrent] = []
    }

    let stateURL: URL
    private let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }()

    init(stateURL: URL = FileManager.default.homeDirectoryForCurrentUser
        .appendingPathComponent(".imessage-agent/locker_state.json")) {
        self.stateURL = stateURL
    }

    func snapshot() -> NativeCurrentPayload {
        guard let data = try? Data(contentsOf: stateURL),
              let state = try? decoder.decode(PersistedState.self, from: data) else {
            return NativeCurrentPayload(current: nil, queueCount: 0, queueBySender: [])
        }
        let items = ([state.current].compactMap { $0 } + state.pending)
        var groups: [String: NativeQueueSender] = [:]
        for item in items {
            guard let message = item.message else { continue }
            let key = message.chatGuid ?? message.handle
            let label = (message.chatDisplayName ?? "").isEmpty ? message.handle : (message.chatDisplayName ?? message.handle)
            let kind = (message.chatGuid?.contains(";") == true) ? "group" : "person"
            if let existing = groups[key] {
                groups[key] = NativeQueueSender(handle: existing.handle, count: existing.count + 1, chatGuid: existing.chatGuid, label: existing.label, kind: existing.kind)
            } else {
                groups[key] = NativeQueueSender(handle: message.handle, count: 1, chatGuid: message.chatGuid, label: label, kind: kind)
            }
        }
        return NativeCurrentPayload(
            current: state.current,
            queueCount: items.count,
            queueBySender: groups.values.sorted { $0.count > $1.count }
        )
    }

    func update(_ change: (inout [String: Any]) -> Void) throws {
        var state: [String: Any] = [:]
        if let data = try? Data(contentsOf: stateURL),
           let value = try? JSONSerialization.jsonObject(with: data),
           let decoded = value as? [String: Any] {
            state = decoded
        }
        change(&state)
        let data = try JSONSerialization.data(withJSONObject: state, options: [.prettyPrinted, .sortedKeys])
        let directory = stateURL.deletingLastPathComponent()
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        let temporary = directory.appendingPathComponent(".locker_state.json.tmp")
        try data.write(to: temporary, options: .atomic)
        if FileManager.default.fileExists(atPath: stateURL.path) {
            _ = try FileManager.default.replaceItemAt(stateURL, withItemAt: temporary)
        } else {
            try FileManager.default.moveItem(at: temporary, to: stateURL)
        }
        try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: stateURL.path)
    }

    func removeCurrent() throws {
        try update { state in state["current"] = NSNull() }
    }

    /// Every chat.db rowid this queue still holds content for.
    func heldRowIDs() -> Set<Int> {
        guard let data = try? Data(contentsOf: stateURL),
              let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
        func rowid(_ item: Any?) -> Int? {
            guard let item = item as? [String: Any],
                  let message = item["message"] as? [String: Any],
                  let value = (message["rowid"] as? NSNumber)?.intValue,
                  value > 0 else { return nil }
            return value
        }
        var found = Set((value["pending"] as? [Any] ?? []).compactMap(rowid))
        if let current = rowid(value["current"]) { found.insert(current) }
        return found
    }

    func lastProcessedRowID() -> Int? {
        guard let data = try? Data(contentsOf: stateURL),
              let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let rowid = (value["last_rowid"] as? NSNumber)?.intValue,
              rowid > 0 else { return nil }
        return rowid
    }

    /// Drop everything this queue holds about `gone`. Returns how many items
    /// were removed, for tests and for callers deciding whether anything
    /// changed. Deliberately not logged: a note that something was purged is
    /// itself a record that something existed.
    @discardableResult
    func purge(_ gone: Set<Int>) throws -> Int {
        guard !gone.isEmpty else { return 0 }
        var removed = 0
        try update { state in
            func isGone(_ item: Any?) -> Bool {
                guard let item = item as? [String: Any],
                      let message = item["message"] as? [String: Any],
                      let rowid = (message["rowid"] as? NSNumber)?.intValue else { return false }
                return gone.contains(rowid)
            }
            let pending = state["pending"] as? [Any] ?? []
            let kept = pending.filter { !isGone($0) }
            removed = pending.count - kept.count
            if removed > 0 { state["pending"] = kept }
            if isGone(state["current"]) {
                removed += 1
                // Left empty rather than advanced. The next status read
                // promotes the head of the queue the way it always does.
                state["current"] = NSNull()
            }
            // `seen` would otherwise keep a deleted rowid out of the queue
            // forever, but it is also a record of a rowid the user deleted.
            if let seen = state["seen"] as? [Any] {
                let keptSeen = seen.filter { value in
                    guard let rowid = (value as? NSNumber)?.intValue else { return true }
                    return !gone.contains(rowid)
                }
                if keptSeen.count != seen.count { state["seen"] = keptSeen }
            }
        }
        return removed
    }

    func ingest(_ messages: [NativeMessage], ignoredContacts: Set<String> = []) throws {
        guard !messages.isEmpty else { return }
        try update { state in
            var pending = state["pending"] as? [[String: Any]] ?? []
            var seen = Set<Int>()
            if let current = state["current"] as? [String: Any],
               let message = current["message"] as? [String: Any],
               let rowid = message["rowid"] as? Int { seen.insert(rowid) }
            for item in pending {
                if let message = item["message"] as? [String: Any], let rowid = message["rowid"] as? Int { seen.insert(rowid) }
            }
            var lastRowID = (state["last_rowid"] as? NSNumber)?.intValue ?? 0
            let encoder = JSONEncoder()
            encoder.keyEncodingStrategy = .convertToSnakeCase
            for message in messages.sorted(by: { $0.rowid < $1.rowid }) {
                lastRowID = max(lastRowID, message.rowid)
                let contact = message.handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
                guard let data = try? encoder.encode(message),
                      let encoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
                if let current = state["current"] as? [String: Any],
                   let currentMessage = current["message"] as? [String: Any],
                   (currentMessage["rowid"] as? NSNumber)?.intValue == message.rowid {
                    state["current"] = refreshedItem(current, message: message, encoded: encoded)
                    seen.insert(message.rowid)
                    continue
                }
                if let index = pending.firstIndex(where: { item in
                    ((item["message"] as? [String: Any])?["rowid"] as? NSNumber)?.intValue == message.rowid
                }) {
                    pending[index] = refreshedItem(pending[index], message: message, encoded: encoded)
                    seen.insert(message.rowid)
                    continue
                }
                if seen.contains(message.rowid) || ignoredContacts.contains(contact) { continue }
                let rule = NativeSensitive.ruleFor(message.text)
                if !rule.isEmpty { NativeSensitiveTally.record(rule) }
                pending.append(["message": encoded, "draft": "", "error": "", "drafting": false, "sensitive": !rule.isEmpty])
                seen.insert(message.rowid)
            }
            state["pending"] = pending
            state["last_rowid"] = lastRowID
        }
    }

    private func refreshedItem(_ item: [String: Any], message: NativeMessage,
                               encoded: [String: Any]) -> [String: Any] {
        var refreshed = item
        guard let stored = item["message"] as? [String: Any],
              let storedData = try? JSONSerialization.data(withJSONObject: stored),
              let storedMessage = try? decoder.decode(NativeMessage.self, from: storedData),
              NativeQueuePolicy.messageChanged(
                storedHandle: storedMessage.handle, storedText: storedMessage.text,
                storedService: storedMessage.service, storedChatGuid: storedMessage.chatGuid,
                incomingHandle: message.handle, incomingText: message.text,
                incomingService: message.service, incomingChatGuid: message.chatGuid
              ) else { return refreshed }
        let rule = NativeSensitive.ruleFor(message.text)
        refreshed["message"] = encoded
        refreshed["draft"] = ""
        refreshed["drafting"] = false
        refreshed["error"] = ""
        refreshed["sensitive"] = !rule.isEmpty
        refreshed["held"] = !rule.isEmpty
        return refreshed
    }

    func updateCurrentDraft(_ draft: String, error: String = "") throws {
        try update { state in
            guard var current = state["current"] as? [String: Any] else { return }
            current["draft"] = draft
            current["drafting"] = false
            current["error"] = error
            state["current"] = current
        }
    }

    func updateCurrentDraft(for message: NativeMessage, _ draft: String, error: String = "") throws {
        try update { state in
            guard var current = state["current"] as? [String: Any],
                  let stored = current["message"] as? [String: Any],
                  let storedData = try? JSONSerialization.data(withJSONObject: stored),
                  let storedMessage = try? decoder.decode(NativeMessage.self, from: storedData),
                  storedMessage == message else { return }
            current["draft"] = draft
            current["drafting"] = false
            current["error"] = error
            state["current"] = current
        }
    }

    func selectSender(handle: String, chatGuid: String?) throws {
        try update { state in
            var pending = state["pending"] as? [[String: Any]] ?? []
            guard let index = pending.firstIndex(where: { item in
                let message = item["message"] as? [String: Any]
                return (chatGuid != nil && message?["chat_guid"] as? String == chatGuid) || (chatGuid == nil && message?["handle"] as? String == handle)
            }) else { return }
            let selected = pending.remove(at: index)
            if let current = state["current"] as? [String: Any] { pending.append(current) }
            state["current"] = selected
            state["pending"] = pending
        }
    }
}

/// The native runtime coordinator. Callers use it directly; it has no
/// localhost listener or child-process dependency.
/// Composes storage, Messages, queue, provider, and voice services into app operations.
/// The launcher and dashboard client use it as the native runtime boundary.
final class NativeEngineService {
    let storage: NativeStorageService
    let messages: NativeMessagesService
    let queue: NativeQueueService
    let provider: NativeProviderService

    init(storage: NativeStorageService = NativeStorageService(),
         messages: NativeMessagesService = NativeMessagesService(),
         queue: NativeQueueService = NativeQueueService(),
         provider: NativeProviderService = NativeProviderService()) {
        self.storage = storage
        self.messages = messages
        self.queue = queue
        self.provider = provider
    }

    func status() -> NativeDashboardStatus {
        let config = storage.load()
        let selected = String(config["draft_provider"] as? String ?? "on_device")
        let modelKey = selected == "self_hosted" ? "self_hosted_model" : "on_device_model"
        let urlKey = selected == "self_hosted" ? "self_hosted_url" : "on_device_url"
        let model = String(config[modelKey] as? String ?? "")
        let url = String(config[urlKey] as? String ?? "")
        let readable = messages.isDatabaseReadable
        // Remember the first success, so a later failure can be told apart from
        // never having been granted. Written once, on the transition only.
        var everGranted = config[NativeMessagesAccess.configKey] as? Bool ?? false
        if readable && !everGranted {
            everGranted = true
            var updated = config
            updated[NativeMessagesAccess.configKey] = true
            try? storage.save(updated)
        }
        let access = NativeMessagesAccess.resolve(readable: readable, everGranted: everGranted)
        let messagesReady = access.isReadable
        let paused = config["paused"] as? Bool ?? false
        var value = NativeDashboardStatus()
        value.appVersion = IMLVersion.current
        value.draftProvider = selected
        value.onDeviceModel = String(config["on_device_model"] as? String ?? "")
        value.onDeviceUrl = String(config["on_device_url"] as? String ?? "")
        value.selfHostedModel = String(config["self_hosted_model"] as? String ?? "")
        value.selfHostedUrl = String(config["self_hosted_url"] as? String ?? "")
        value.cloudUrl = String(config["cloud_url"] as? String ?? "https://api.openai.com/v1/responses")
        // Readiness comes from a probe, not from whether Settings has been
        // filled in. See NativeProviderReach.
        let reach = provider.reach(for: NativeProviderConfigurationRequest(
            provider: selected,
            model: model,
            url: selected == "cloud" ? value.cloudUrl : url,
            apiKey: configurationValue(config, key: "openai_api_key")
        ))
        // Only the selected provider is probed - the others are not in use, and
        // reporting them as not-ready would put three warnings on screen for one
        // problem.
        value.onDeviceReady = selected != "on_device" || reach.canDraft
        value.selfHostedReady = selected != "self_hosted" || reach.canDraft
        value.cloudReady = selected != "cloud" || reach.canDraft
        if case .unconfigured = reach {
            value.providerSetupRequired = true
        } else {
            value.providerSetupRequired = false
        }
        // The persona engine, restored in NativeVoice. Choosing "Professional"
        // used to change nothing about a draft; the four attributes that make it
        // professional now reach the prompt as prose.
        let learned = NativeVoice.learned(from: config["learned_voice"])
        let overrides = NativeVoice.overrides(from: config["persona_overrides"])
        var active = NativeVoice.active(id: config["active_persona_id"] as? String,
                                        library: config["persona_library"],
                                        overrides: overrides)
        // The free-text box is the persona's note. A user who typed one before
        // attributes existed keeps their words.
        let typedNote = String(config["persona"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        if !typedNote.isEmpty && active.note.isEmpty { active.note = typedNote }
        value.activePersonaId = active.id
        value.activePersonaName = active.name
        value.persona = typedNote
        value.voiceInstruction = NativeVoice.instruction(for: active, learned: learned)
        value.wordTarget = NativeVoice.wordTarget(learned)
        value.personaChoices = NativeVoice.all(library: config["persona_library"],
                                               overrides: overrides).map {
            NativePersonaChoice(id: $0.id, name: $0.name, blurb: $0.blurb, seeded: $0.isSeed)
        }
        value.activePersonaProfile = [
            "formality": active.profile.formality,
            "directness": active.profile.directness,
            "sarcasm": active.profile.sarcasm,
            "profanity": active.profile.profanity
        ]
        value.learnedVoice = ["warmth": learned.warmth, "brevity": learned.brevity]
        value.activePersonaModified = NativeVoice.isModified(active.id, overrides: overrides)
        value.ignoredContacts = config["ignored_contacts"] as? [String] ?? []
        value.keepRepliesClean = config["keep_replies_clean"] as? Bool ?? false
        value.intentAckRequired = !(config["intent_ack_complete"] as? Bool ?? false)
        value.sendAckRequired = !(config["send_ack_complete"] as? Bool ?? false)
        value.messagesReady = messagesReady
        value.messagesAccessState = {
            switch access {
            case .granted: return "granted"
            case .neverGranted: return "never_granted"
            case .revoked: return "revoked"
            }
        }()
        value.messagesConnection = NativeMessagesConnection(
            state: {
                switch access {
                case .granted: return "ready"
                case .neverGranted: return "never_granted"
                case .revoked: return "revoked"
                }
            }(),
            ready: messagesReady,
            appRunning: NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.MobileSMS").isEmpty == false,
            detail: access.detail
        )
        value.providerStatus = NativeProviderStatus(
            provider: selected,
            ready: reach.canDraft,
            label: reach.label,
            detail: reach.detail
        )
        value.paused = paused
        value.nativeStatus = NativeNativeStatus(
            state: paused ? "paused" : "ready",
            label: paused ? "Paused" : "Ready",
            reason: paused ? "Drafting is paused." : "Native engine is running.",
            paused: paused,
            condition: nil
        )
        return value
    }

    private func configurationValue(_ config: [String: Any], key: String) -> String {
        String(config[key] as? String ?? "")
    }

    func current() throws -> NativeCurrentPayload {
        let config = storage.load()
        let ignored = Set((config["ignored_contacts"] as? [String] ?? []).map { $0.lowercased() })
        // A blocked Messages database must not hide already-persisted work.
        // The queue remains usable while the user repairs Full Disk Access.
        let held = queue.heldRowIDs()
        if let incoming = try? messages.incomingMessages(afterRowID: queue.lastProcessedRowID()),
           let refreshed = try? messages.messages(forRowIDs: held) {
            try queue.ingest(incoming + refreshed, ignoredContacts: ignored)
        } else if let incoming = try? messages.incomingMessages(afterRowID: queue.lastProcessedRowID()) {
            try queue.ingest(incoming, ignoredContacts: ignored)
        }
        sweepDeletions()
        return queue.snapshot()
    }

    /// Stop holding anything the user deleted in Messages.
    ///
    /// Runs on the same path that already reads chat.db, so it costs one extra
    /// query against rowids iML is already keeping and reads no message the app
    /// was not already holding.
    ///
    /// Returns how many items were purged. Silent by construction: nothing is
    /// logged, because a note that something was purged is itself a record that
    /// something existed.
    @discardableResult
    func sweepDeletions() -> Int {
        let candidates = queue.heldRowIDs()
        guard !candidates.isEmpty else { return 0 }
        let ceiling: Int
        let live: Set<Int>
        do {
            ceiling = try NativeDeletionSweep.maxMessageRowID(databaseURL: messages.databaseURL)
            live = try NativeDeletionSweep.liveRowIDs(candidates, databaseURL: messages.databaseURL)
        } catch {
            // An unreadable chat.db means no answer, not "everything was
            // deleted". Doing nothing is the only safe reading, and this is the
            // path taken when Full Disk Access is revoked or Messages is
            // mid-migration.
            return 0
        }
        let gone = NativeDeletionSweep.goneRowIDs(candidates: candidates, live: live, ceiling: ceiling)
        return (try? queue.purge(gone)) ?? 0
    }

    func saveConfig(_ values: [String: Any]) throws {
        var config = storage.load()
        config.merge(values) { _, new in new }
        try storage.save(config)
    }

    func sendCurrent() throws {
        let payload = try current()
        guard let message = payload.current?.message,
              let draft = payload.current?.draft,
              !draft.isEmpty else { throw NativeServiceError.sendFailed("There is no completed draft to send.") }
        try messages.send(body: draft, to: message.handle)
        try queue.removeCurrent()
    }

    func skipCurrent() throws { try queue.removeCurrent() }

    func setPaused(_ paused: Bool) throws {
        var config = storage.load()
        config["paused"] = paused
        try storage.save(config)
    }
}
