//
//  DatabaseService.swift
//  Confab
//
//  SQLite persistence layer for conversations using GRDB
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.


import Foundation
import GRDB
import os

// MARK: - Database Record Types

struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "conversations"

    var id: String
    var name: String
    var createdAt: String
    var updatedAt: String
    var primaryModel: String?
    var folderId: String?
    var notesEnabled: Bool = false
    var notesFilename: String?
}

struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "folders"

    var id: String
    var name: String
    var sortOrder: Int
    var createdAt: String
    var parentId: String?
    var updatedAt: String?
}

struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "messages"

    var id: String
    var conversationId: String
    var role: String
    var content: String
    var tokens: Int?
    var cost: Double?
    var timestamp: String
    var sortOrder: Int
    var modelId: String?
}

struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "settings"

    var key: String
    var value: String
}

struct HistoryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "command_history"

    var id: String
    var input: String
    var timestamp: String
}

struct EmailLogRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "email_logs"

    var id: String
    var timestamp: String
    var sender: String
    var subject: String
    var emailContent: String
    var aiResponse: String?
    var status: String // "success" or "error"
    var errorMessage: String?
    var tokens: Int?
    var cost: Double?
    var responseTime: Double?
    var modelId: String?
}

struct MessageMetadataRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "message_metadata"

    var message_id: String
    var importance_score: Double
    var user_starred: Int
    var summary: String?
    var chunk_index: Int
}

struct MessageEmbeddingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "message_embeddings"

    var message_id: String
    var embedding: Data
    var embedding_model: String
    var embedding_dimension: Int
    var created_at: String
}

struct ConversationEmbeddingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "conversation_embeddings"

    var conversation_id: String
    var embedding: Data
    var embedding_model: String
    var embedding_dimension: Int
    var created_at: String
}

struct ConversationSummaryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
    static let databaseTableName = "conversation_summaries"

    var id: String
    var conversation_id: String
    var start_message_index: Int
    var end_message_index: Int
    var summary: String
    var token_count: Int?
    var created_at: String
    var summary_model: String?
}

// MARK: - DatabaseService

final class DatabaseService: Sendable {
    nonisolated static let shared = DatabaseService()

    private let dbQueue: DatabaseQueue

    // Command history limit - keep most recent 5000 entries
    private nonisolated static let maxHistoryEntries = 5000

    // ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
    private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
        dateSeparator: .dash,
        dateTimeSeparator: .standard,
        timeSeparator: .colon,
        timeZoneSeparator: .colon,
        includingFractionalSeconds: true,
        timeZone: .gmt
    )

    private nonisolated static func isoString(from date: Date) -> String {
        isoStyle.format(date)
    }

    private nonisolated static func isoDate(from string: String) -> Date? {
        (try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
    }

    /// Test seam: build a DatabaseService around a caller-provided queue (e.g. an in-memory `DatabaseQueue()`).
    /// Each test should create its own fresh instance — never mutate `.shared` from a test.
    nonisolated init(dbQueue: DatabaseQueue) {
        self.dbQueue = dbQueue
        try! migrator.migrate(dbQueue)
    }

    nonisolated private convenience init() {
        let fileManager = FileManager.default
        let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
        let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)

        try! fileManager.createDirectory(at: dbDirectory, withIntermediateDirectories: true)

        let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path
        Log.db.info("Opening database at \(dbPath)")
        self.init(dbQueue: try! DatabaseQueue(path: dbPath))
    }

    /// Test seam: a throwaway in-memory instance with all migrations applied. Avoids requiring
    /// `import GRDB` from the test target just to construct a `DatabaseQueue()`.
    nonisolated static func makeInMemory() -> DatabaseService {
        DatabaseService(dbQueue: try! DatabaseQueue())
    }

    /// Test seam: schema introspection, so migration tests don't need `import GRDB` either.
    nonisolated func tableExists(_ name: String) -> Bool {
        (try? dbQueue.read { db in try db.tableExists(name) }) ?? false
    }

    nonisolated func columnNames(in table: String) -> [String] {
        (try? dbQueue.read { db in try db.columns(in: table).map(\.name) }) ?? []
    }

    nonisolated var migrator: DatabaseMigrator {
        var migrator = DatabaseMigrator()

        migrator.registerMigration("v1") { db in
            try db.create(table: "conversations") { t in
                t.primaryKey("id", .text)
                t.column("name", .text).notNull()
                t.column("createdAt", .text).notNull()
                t.column("updatedAt", .text).notNull()
            }

            try db.create(table: "messages") { t in
                t.primaryKey("id", .text)
                t.column("conversationId", .text).notNull()
                    .references("conversations", onDelete: .cascade)
                t.column("role", .text).notNull()
                t.column("content", .text).notNull()
                t.column("tokens", .integer)
                t.column("cost", .double)
                t.column("timestamp", .text).notNull()
                t.column("sortOrder", .integer).notNull()
            }

            try db.create(
                index: "messages_on_conversationId",
                on: "messages",
                columns: ["conversationId"]
            )
        }

        migrator.registerMigration("v2") { db in
            try db.create(table: "settings") { t in
                t.primaryKey("key", .text)
                t.column("value", .text).notNull()
            }
        }

        migrator.registerMigration("v3") { db in
            try db.create(table: "command_history") { t in
                t.primaryKey("id", .text)
                t.column("input", .text).notNull()
                t.column("timestamp", .text).notNull()
            }

            try db.create(
                index: "command_history_on_timestamp",
                on: "command_history",
                columns: ["timestamp"]
            )
        }

        migrator.registerMigration("v4") { db in
            // Add modelId to messages table (nullable for existing messages)
            try db.alter(table: "messages") { t in
                t.add(column: "modelId", .text)
            }

            // Add primaryModel to conversations table (nullable)
            try db.alter(table: "conversations") { t in
                t.add(column: "primaryModel", .text)
            }
        }

        migrator.registerMigration("v5") { db in
            // Email handler logs
            try db.create(table: "email_logs") { t in
                t.primaryKey("id", .text)
                t.column("timestamp", .text).notNull()
                t.column("sender", .text).notNull()
                t.column("subject", .text).notNull()
                t.column("emailContent", .text).notNull()
                t.column("aiResponse", .text)
                t.column("status", .text).notNull() // "success" or "error"
                t.column("errorMessage", .text)
                t.column("tokens", .integer)
                t.column("cost", .double)
                t.column("responseTime", .double)
                t.column("modelId", .text)
            }

            try db.create(
                index: "email_logs_on_timestamp",
                on: "email_logs",
                columns: ["timestamp"]
            )
        }

        migrator.registerMigration("v6") { db in
            // Message metadata for smart context selection
            try db.create(table: "message_metadata") { t in
                t.primaryKey("message_id", .text)
                    .references("messages", onDelete: .cascade)
                t.column("importance_score", .double).notNull().defaults(to: 0.0)
                t.column("user_starred", .integer).notNull().defaults(to: 0)
                t.column("summary", .text)
                t.column("chunk_index", .integer).notNull().defaults(to: 0)
            }

            try db.create(
                index: "idx_message_metadata_importance",
                on: "message_metadata",
                columns: ["importance_score"]
            )

            try db.create(
                index: "idx_message_metadata_starred",
                on: "message_metadata",
                columns: ["user_starred"]
            )
        }

        migrator.registerMigration("v7") { db in
            // Message embeddings for semantic search
            try db.create(table: "message_embeddings") { t in
                t.primaryKey("message_id", .text)
                    .references("messages", onDelete: .cascade)
                t.column("embedding", .blob).notNull()
                t.column("embedding_model", .text).notNull()
                t.column("embedding_dimension", .integer).notNull()
                t.column("created_at", .text).notNull()
            }

            // Conversation embeddings (aggregate of all messages)
            try db.create(table: "conversation_embeddings") { t in
                t.primaryKey("conversation_id", .text)
                    .references("conversations", onDelete: .cascade)
                t.column("embedding", .blob).notNull()
                t.column("embedding_model", .text).notNull()
                t.column("embedding_dimension", .integer).notNull()
                t.column("created_at", .text).notNull()
            }
        }

        migrator.registerMigration("v8") { db in
            // Conversation summaries for progressive summarization
            try db.create(table: "conversation_summaries") { t in
                t.primaryKey("id", .text)
                t.column("conversation_id", .text).notNull()
                    .references("conversations", onDelete: .cascade)
                t.column("start_message_index", .integer).notNull()
                t.column("end_message_index", .integer).notNull()
                t.column("summary", .text).notNull()
                t.column("token_count", .integer)
                t.column("created_at", .text).notNull()
                t.column("summary_model", .text)
            }

            try db.create(
                index: "idx_conversation_summaries_conv",
                on: "conversation_summaries",
                columns: ["conversation_id"]
            )
        }

        migrator.registerMigration("v9") { db in
            // Folders for organizing conversations
            try db.create(table: "folders") { t in
                t.primaryKey("id", .text)
                t.column("name", .text).notNull()
                t.column("sortOrder", .integer).notNull().defaults(to: 0)
                t.column("createdAt", .text).notNull()
            }

            try db.alter(table: "conversations") { t in
                t.add(column: "folderId", .text).references("folders", onDelete: .setNull)
            }
        }

        migrator.registerMigration("v10") { db in
            // Nested folders: a folder may live under another folder. ON DELETE RESTRICT (not
            // CASCADE/SET NULL) is a defensive backstop — deleteFolder() always reparents
            // children/conversations before deleting the row, in one transaction, so by the time
            // DELETE runs nothing should reference it. RESTRICT throws loudly if that invariant
            // is ever violated, instead of silently promoting things to top-level or cascading a
            // delete through a whole subtree.
            try db.alter(table: "folders") { t in
                t.add(column: "parentId", .text)
                    .references("folders", onDelete: .restrict)
            }
            try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"])
        }

        migrator.registerMigration("v11") { db in
            // Needed to resolve folder renames/reparents last-write-wins when Git Sync brings in
            // folder state from another machine — without a timestamp there's no way to tell whose
            // version of a rename is newer. Backfilled from createdAt for existing rows.
            try db.alter(table: "folders") { t in
                t.add(column: "updatedAt", .text)
            }
            try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL")
        }

        migrator.registerMigration("v12") { db in
            // Per-conversation notes.md: opt-in persistent memory file, auto-read/written by the
            // model. notesFilename is a local lookup pointer only — the file's embedded **ID**
            // line is the actual source of truth (see ConversationNotesService).
            try db.alter(table: "conversations") { t in
                t.add(column: "notesEnabled", .boolean).notNull().defaults(to: false)
                t.add(column: "notesFilename", .text)
            }
        }

        return migrator
    }

    // MARK: - Settings Operations

    nonisolated func loadAllSettings() throws -> [String: String] {
        try dbQueue.read { db in
            let records = try SettingRecord.fetchAll(db)
            return Dictionary(uniqueKeysWithValues: records.map { ($0.key, $0.value) })
        }
    }

    nonisolated func getSetting(key: String) -> String? {
        try? dbQueue.read { db in
            try SettingRecord.fetchOne(db, key: key)?.value
        }
    }

    nonisolated func setSetting(key: String, value: String) {
        try? dbQueue.write { db in
            let record = SettingRecord(key: key, value: value)
            try record.save(db)
        }
    }

    nonisolated func deleteSetting(key: String) {
        try? dbQueue.write { db in
            _ = try SettingRecord.deleteOne(db, key: key)
        }
    }

    // MARK: - Encrypted Settings Operations

    /// Store an encrypted setting (for sensitive data like API keys)
    nonisolated func setEncryptedSetting(key: String, value: String) throws {
        let encryptedValue = try EncryptionService.shared.encrypt(value)
        try dbQueue.write { db in
            let record = SettingRecord(key: "encrypted_\(key)", value: encryptedValue)
            try record.save(db)
        }
    }

    /// Retrieve and decrypt an encrypted setting
    nonisolated func getEncryptedSetting(key: String) throws -> String? {
        let encryptedValue = try dbQueue.read { db in
            try SettingRecord.fetchOne(db, key: "encrypted_\(key)")?.value
        }

        guard let encryptedValue = encryptedValue else {
            return nil
        }

        return try EncryptionService.shared.decrypt(encryptedValue)
    }

    /// Delete an encrypted setting
    nonisolated func deleteEncryptedSetting(key: String) {
        try? dbQueue.write { db in
            _ = try SettingRecord.deleteOne(db, key: "encrypted_\(key)")
        }
    }

    // MARK: - Conversation Operations

    nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation {
        return try saveConversation(id: UUID(), name: name, messages: messages, primaryModel: nil)
    }

    nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?, folderId: UUID? = nil) throws -> Conversation {
        Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
        let now = Date()
        let nowString = Self.isoString(from: now)

        let convRecord = ConversationRecord(
            id: id.uuidString,
            name: name,
            createdAt: nowString,
            updatedAt: nowString,
            primaryModel: primaryModel,
            folderId: folderId?.uuidString,
            notesEnabled: false,
            notesFilename: nil
        )

        let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
            guard msg.role != .system else { return nil }
            return MessageRecord(
                id: UUID().uuidString,
                conversationId: id.uuidString,
                role: msg.role.rawValue,
                content: msg.content,
                tokens: msg.tokens,
                cost: msg.cost,
                timestamp: Self.isoString(from: msg.timestamp),
                sortOrder: index,
                modelId: msg.modelId
            )
        }

        try dbQueue.write { db in
            try convRecord.insert(db)
            for record in messageRecords {
                try record.insert(db)
            }
        }

        let savedMessages = messages.filter { $0.role != .system }
        return Conversation(
            id: id,
            name: name,
            messages: savedMessages,
            createdAt: now,
            updatedAt: now,
            primaryModel: primaryModel,
            folderId: folderId
        )
    }

    /// Update an existing conversation in-place: rename it, replace all its messages.
    nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
        let nowString = Self.isoString(from: Date())

        let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
            guard msg.role != .system else { return nil }
            return MessageRecord(
                id: UUID().uuidString,
                conversationId: id.uuidString,
                role: msg.role.rawValue,
                content: msg.content,
                tokens: msg.tokens,
                cost: msg.cost,
                timestamp: Self.isoString(from: msg.timestamp),
                sortOrder: index,
                modelId: msg.modelId
            )
        }

        try dbQueue.write { db in
            try db.execute(
                sql: "UPDATE conversations SET name = ?, updatedAt = ?, primaryModel = ? WHERE id = ?",
                arguments: [name, nowString, primaryModel, id.uuidString]
            )
            try db.execute(
                sql: "DELETE FROM messages WHERE conversationId = ?",
                arguments: [id.uuidString]
            )
            for record in messageRecords {
                try record.insert(db)
            }
        }
    }

    nonisolated func loadConversation(id: UUID) throws -> (Conversation, [Message])? {
        try dbQueue.read { db in
            guard let convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
                return nil
            }

            let messageRecords = try MessageRecord
                .filter(Column("conversationId") == id.uuidString)
                .order(Column("sortOrder"))
                .fetchAll(db)

            let messages = messageRecords.compactMap { record -> Message? in
                guard let msgId = UUID(uuidString: record.id),
                      let role = MessageRole(rawValue: record.role),
                      let timestamp = Self.isoDate(from: record.timestamp)
                else { return nil }

                let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
                var message = Message(
                    id: msgId,
                    role: role,
                    content: record.content,
                    tokens: record.tokens,
                    cost: record.cost,
                    timestamp: timestamp,
                    modelId: record.modelId
                )
                message.isStarred = starred
                return message
            }

            guard let convId = UUID(uuidString: convRecord.id),
                  let createdAt = Self.isoDate(from: convRecord.createdAt),
                  let updatedAt = Self.isoDate(from: convRecord.updatedAt)
            else { return nil }

            let conversation = Conversation(
                id: convId,
                name: convRecord.name,
                messages: messages,
                createdAt: createdAt,
                updatedAt: updatedAt,
                primaryModel: convRecord.primaryModel,
                folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) },
                notesEnabled: convRecord.notesEnabled,
                notesFilename: convRecord.notesFilename
            )

            return (conversation, messages)
        }
    }

    nonisolated func listConversations() throws -> [Conversation] {
        try dbQueue.read { db in
            let records = try ConversationRecord
                .order(Column("updatedAt").desc)
                .fetchAll(db)

            return records.compactMap { record -> Conversation? in
                guard let id = UUID(uuidString: record.id),
                      let createdAt = Self.isoDate(from: record.createdAt),
                      let updatedAt = Self.isoDate(from: record.updatedAt)
                else { return nil }

                // Fetch message count without loading all messages
                let messageCount = (try? MessageRecord
                    .filter(Column("conversationId") == record.id)
                    .fetchCount(db)) ?? 0

                // Get last message (for date + model fallback)
                let lastMsg = try? MessageRecord
                    .filter(Column("conversationId") == record.id)
                    .order(Column("sortOrder").desc)
                    .fetchOne(db)

                let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt

                // Derive primary model: prefer the stored field, fall back to last message's modelId
                let primaryModel = record.primaryModel ?? lastMsg?.modelId

                // Create conversation with empty messages array but correct metadata
                var conv = Conversation(
                    id: id,
                    name: record.name,
                    messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
                    createdAt: createdAt,
                    updatedAt: lastDate,
                    primaryModel: primaryModel,
                    folderId: record.folderId.flatMap { UUID(uuidString: $0) },
                    notesEnabled: record.notesEnabled,
                    notesFilename: record.notesFilename
                )
                conv.updatedAt = lastDate
                return conv
            }
        }
    }

    // MARK: - Folders

    enum FolderError: Error, Sendable {
        case wouldCreateCycle
    }

    nonisolated func createFolder(name: String, parentId: UUID? = nil) throws -> Folder {
        let folder = Folder(name: name, sortOrder: try nextFolderSortOrder(), parentId: parentId)
        let record = FolderRecord(
            id: folder.id.uuidString,
            name: folder.name,
            sortOrder: folder.sortOrder,
            createdAt: Self.isoString(from: folder.createdAt),
            parentId: parentId?.uuidString,
            updatedAt: Self.isoString(from: folder.updatedAt)
        )
        try dbQueue.write { db in
            try record.insert(db)
        }
        return folder
    }

    private nonisolated func nextFolderSortOrder() throws -> Int {
        try dbQueue.read { db in
            let row = try Row.fetchOne(db, sql: "SELECT MAX(sortOrder) AS maxOrder FROM folders")
            let maxOrder: Int? = row?["maxOrder"]
            return (maxOrder ?? -1) + 1
        }
    }

    nonisolated func renameFolder(id: UUID, name: String) throws {
        try dbQueue.write { db in
            try db.execute(
                sql: "UPDATE folders SET name = ?, updatedAt = ? WHERE id = ?",
                arguments: [name, Self.isoString(from: Date()), id.uuidString]
            )
        }
    }

    /// Reparents a folder (nil = promote to top level). Throws `.wouldCreateCycle` if `parentId`
    /// is the folder itself or one of its own descendants.
    nonisolated func moveFolder(id: UUID, toParent parentId: UUID?) throws {
        try dbQueue.write { db in
            if let parentId {
                guard parentId != id else { throw FolderError.wouldCreateCycle }
                let folders = try FolderRecord.fetchAll(db).compactMap(Self.folder(from:))
                guard !Folder.isDescendant(parentId, of: id, in: folders) else {
                    throw FolderError.wouldCreateCycle
                }
            }
            try db.execute(sql: "UPDATE folders SET parentId = ?, updatedAt = ? WHERE id = ?",
                            arguments: [parentId?.uuidString, Self.isoString(from: Date()), id.uuidString])
        }
    }

    /// Creates or updates a folder with an externally-supplied identity and timestamps, for Git
    /// Sync import — as opposed to `createFolder`, which is for user-initiated creation and always
    /// mints a fresh id/timestamps. Last-write-wins: if the folder already exists locally, only
    /// overwrites name/parentId when `updatedAt` is strictly newer than the local row's.
    nonisolated func upsertSyncedFolder(id: UUID, name: String, parentId: UUID?, createdAt: Date, updatedAt: Date) throws {
        let existing = try dbQueue.read { db in
            try FolderRecord.fetchOne(db, key: id.uuidString)
        }
        if let existing {
            guard let existingUpdatedAt = existing.updatedAt.flatMap(Self.isoDate(from:)),
                  existingUpdatedAt < updatedAt
            else { return }
            try dbQueue.write { db in
                try db.execute(
                    sql: "UPDATE folders SET name = ?, parentId = ?, updatedAt = ? WHERE id = ?",
                    arguments: [name, parentId?.uuidString, Self.isoString(from: updatedAt), id.uuidString]
                )
            }
        } else {
            let record = FolderRecord(
                id: id.uuidString, name: name, sortOrder: try nextFolderSortOrder(),
                createdAt: Self.isoString(from: createdAt), parentId: parentId?.uuidString,
                updatedAt: Self.isoString(from: updatedAt)
            )
            try dbQueue.write { db in
                try record.insert(db)
            }
        }
    }

    /// Deletes a folder, reparenting everything directly inside it (child folders + conversations
    /// filed directly in it) up one level to the deleted folder's own parent. Conversations are
    /// never deleted. All statements run in one transaction, satisfying the ON DELETE RESTRICT
    /// backstop (reparent happens before the DELETE).
    nonisolated func deleteFolder(id: UUID) throws {
        try dbQueue.write { db in
            guard let ownRecord = try FolderRecord.fetchOne(db, key: id.uuidString) else { return }
            let parentIdString = ownRecord.parentId
            try db.execute(sql: "UPDATE folders SET parentId = ? WHERE parentId = ?",
                            arguments: [parentIdString, id.uuidString])
            try db.execute(sql: "UPDATE conversations SET folderId = ? WHERE folderId = ?",
                            arguments: [parentIdString, id.uuidString])
            _ = try FolderRecord.deleteOne(db, key: id.uuidString)
        }
    }

    nonisolated func listFolders() throws -> [Folder] {
        try dbQueue.read { db in
            // Alphabetical, not creation order (sortOrder) — folders should sort
            // predictably by name everywhere they're listed.
            let records = try FolderRecord.fetchAll(db, sql: "SELECT * FROM folders ORDER BY name COLLATE NOCASE")
            return records.compactMap(Self.folder(from:))
        }
    }

    private nonisolated static func folder(from record: FolderRecord) -> Folder? {
        guard let id = UUID(uuidString: record.id),
              let createdAt = Self.isoDate(from: record.createdAt)
        else { return nil }
        // Falls back to createdAt if updatedAt is somehow missing (shouldn't happen post-v11
        // migration, which backfills every existing row) rather than failing the whole fetch.
        let updatedAt = record.updatedAt.flatMap(Self.isoDate(from:)) ?? createdAt
        return Folder(
            id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt,
            parentId: record.parentId.flatMap { UUID(uuidString: $0) }, updatedAt: updatedAt
        )
    }

    nonisolated func moveConversation(id: UUID, toFolder folderId: UUID?) throws {
        try dbQueue.write { db in
            try db.execute(
                sql: "UPDATE conversations SET folderId = ? WHERE id = ?",
                arguments: [folderId?.uuidString, id.uuidString]
            )
        }
    }

    nonisolated func setNotesEnabled(id: UUID, enabled: Bool) throws {
        try dbQueue.write { db in
            try db.execute(
                sql: "UPDATE conversations SET notesEnabled = ? WHERE id = ?",
                arguments: [enabled, id.uuidString]
            )
        }
    }

    nonisolated func setNotesFilename(id: UUID, filename: String?) throws {
        try dbQueue.write { db in
            try db.execute(
                sql: "UPDATE conversations SET notesFilename = ? WHERE id = ?",
                arguments: [filename, id.uuidString]
            )
        }
    }

    // MARK: - Usage Statistics

    nonisolated func getOverallUsageStats() throws -> UsageStats {
        try dbQueue.read { db in
            guard let row = try Row.fetchOne(db, sql: """
                SELECT COUNT(*) AS cnt,
                       COALESCE(SUM(tokens), 0) AS tokens,
                       COALESCE(SUM(cost), 0) AS cost,
                       COUNT(cost) AS costCount,
                       MIN(timestamp) AS minTs,
                       MAX(timestamp) AS maxTs
                FROM messages
                """)
            else {
                return UsageStats()
            }

            let costCount: Int = row["costCount"]
            let minTs: String? = row["minTs"]
            let maxTs: String? = row["maxTs"]

            return UsageStats(
                totalMessages: row["cnt"],
                totalTokens: row["tokens"],
                totalCost: row["cost"],
                hasCostData: costCount > 0,
                firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
                lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
            )
        }
    }

    nonisolated func getUsageByModel() throws -> [ModelUsageStat] {
        try dbQueue.read { db in
            let rows = try Row.fetchAll(db, sql: """
                SELECT modelId,
                       COUNT(*) AS cnt,
                       COALESCE(SUM(tokens), 0) AS tokens,
                       COALESCE(SUM(cost), 0) AS cost,
                       COUNT(cost) AS costCount,
                       MAX(timestamp) AS lastUsed
                FROM messages
                WHERE modelId IS NOT NULL
                GROUP BY modelId
                """)

            let stats: [ModelUsageStat] = rows.compactMap { row in
                guard let modelId: String = row["modelId"],
                      let lastUsedString: String = row["lastUsed"],
                      let lastUsed = Self.isoDate(from: lastUsedString)
                else { return nil }

                let costCount: Int = row["costCount"]
                return ModelUsageStat(
                    modelId: modelId,
                    messageCount: row["cnt"],
                    totalTokens: row["tokens"],
                    totalCost: row["cost"],
                    hasCostData: costCount > 0,
                    lastUsed: lastUsed
                )
            }

            return stats.sorted { lhs, rhs in
                if lhs.hasCostData || rhs.hasCostData {
                    return lhs.totalCost > rhs.totalCost
                }
                return lhs.totalTokens > rhs.totalTokens
            }
        }
    }

    nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
        try dbQueue.read { db in
            let rows = try Row.fetchAll(db, sql: """
                SELECT m.conversationId AS conversationId,
                       c.name AS name,
                       COUNT(*) AS cnt,
                       COALESCE(SUM(m.tokens), 0) AS tokens,
                       COALESCE(SUM(m.cost), 0) AS cost,
                       COUNT(m.cost) AS costCount
                FROM messages m
                JOIN conversations c ON m.conversationId = c.id
                GROUP BY m.conversationId
                """)

            let stats: [ConversationUsageStat] = rows.compactMap { row in
                guard let conversationIdString: String = row["conversationId"],
                      let conversationId = UUID(uuidString: conversationIdString)
                else { return nil }

                let costCount: Int = row["costCount"]
                return ConversationUsageStat(
                    conversationId: conversationId,
                    name: row["name"],
                    messageCount: row["cnt"],
                    totalTokens: row["tokens"],
                    totalCost: row["cost"],
                    hasCostData: costCount > 0
                )
            }

            let sorted = stats.sorted { lhs, rhs in
                if lhs.hasCostData || rhs.hasCostData {
                    return lhs.totalCost > rhs.totalCost
                }
                return lhs.totalTokens > rhs.totalTokens
            }
            return Array(sorted.prefix(limit))
        }
    }

    nonisolated func deleteConversation(id: UUID) throws -> Bool {
        Log.db.info("Deleting conversation \(id.uuidString)")
        let result = try dbQueue.write { db -> (Bool, String?) in
            let notesFilename = try ConversationRecord.fetchOne(db, key: id.uuidString)?.notesFilename
            try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
            let deleted = try ConversationRecord.deleteOne(db, key: id.uuidString)
            return (deleted, notesFilename)
        }
        if let notesFilename = result.1 {
            ConversationNotesService.shared.delete(filename: notesFilename)
        }
        return result.0
    }

    nonisolated func deleteConversation(name: String) throws -> Bool {
        let result = try dbQueue.write { db -> (Bool, String?) in
            guard let record = try ConversationRecord
                .filter(Column("name") == name)
                .fetchOne(db)
            else { return (false, nil) }

            try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db)
            let deleted = try ConversationRecord.deleteOne(db, key: record.id)
            return (deleted, record.notesFilename)
        }
        if let notesFilename = result.1 {
            ConversationNotesService.shared.delete(filename: notesFilename)
        }
        return result.0
    }

    nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool {
        try dbQueue.write { db in
            guard var convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
                return false
            }

            if let name = name {
                convRecord.name = name
            }

            convRecord.updatedAt = Self.isoString(from: Date())
            try convRecord.update(db)

            if let messages = messages {
                try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)

                let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
                    guard msg.role != .system else { return nil }
                    return MessageRecord(
                        id: UUID().uuidString,
                        conversationId: id.uuidString,
                        role: msg.role.rawValue,
                        content: msg.content,
                        tokens: msg.tokens,
                        cost: msg.cost,
                        timestamp: Self.isoString(from: msg.timestamp),
                        sortOrder: index
                    )
                }

                for record in messageRecords {
                    try record.insert(db)
                }
            }

            return true
        }
    }

    // MARK: - Command History Operations

    nonisolated func saveCommandHistory(input: String) {
        let now = Date()
        let record = HistoryRecord(
            id: UUID().uuidString,
            input: input,
            timestamp: Self.isoString(from: now)
        )

        try? dbQueue.write { db in
            try record.insert(db)

            // Clean up old entries if we exceed the limit
            let count = try HistoryRecord.fetchCount(db)
            if count > Self.maxHistoryEntries {
                // Delete oldest entries to get back to limit
                let excess = count - Self.maxHistoryEntries
                try db.execute(
                    sql: """
                    DELETE FROM command_history
                    WHERE id IN (
                        SELECT id FROM command_history
                        ORDER BY timestamp ASC
                        LIMIT ?
                    )
                    """,
                    arguments: [excess]
                )
            }
        }
    }

    nonisolated func loadCommandHistory() throws -> [(input: String, timestamp: Date)] {
        try dbQueue.read { db in
            let records = try HistoryRecord
                .order(Column("timestamp").desc)
                .fetchAll(db)

            return records.compactMap { record in
                guard let date = Self.isoDate(from: record.timestamp) else {
                    return nil
                }
                return (input: record.input, timestamp: date)
            }
        }
    }

    nonisolated func searchCommandHistory(query: String) throws -> [(input: String, timestamp: Date)] {
        try dbQueue.read { db in
            let records = try HistoryRecord
                .filter(Column("input").like("%\(query)%"))
                .order(Column("timestamp").desc)
                .fetchAll(db)

            return records.compactMap { record in
                guard let date = Self.isoDate(from: record.timestamp) else {
                    return nil
                }
                return (input: record.input, timestamp: date)
            }
        }
    }

    // MARK: - Email Log Operations

    nonisolated func saveEmailLog(_ log: EmailLog) {
        let record = EmailLogRecord(
            id: log.id.uuidString,
            timestamp: Self.isoString(from: log.timestamp),
            sender: log.sender,
            subject: log.subject,
            emailContent: log.emailContent,
            aiResponse: log.aiResponse,
            status: log.status.rawValue,
            errorMessage: log.errorMessage,
            tokens: log.tokens,
            cost: log.cost,
            responseTime: log.responseTime,
            modelId: log.modelId
        )

        try? dbQueue.write { db in
            try record.insert(db)
        }
    }

    nonisolated func loadEmailLogs(limit: Int = 100) throws -> [EmailLog] {
        try dbQueue.read { db in
            let records = try EmailLogRecord
                .order(Column("timestamp").desc)
                .limit(limit)
                .fetchAll(db)

            return records.compactMap { record in
                guard let timestamp = Self.isoDate(from: record.timestamp),
                      let status = EmailLogStatus(rawValue: record.status),
                      let id = UUID(uuidString: record.id) else {
                    return nil
                }

                return EmailLog(
                    id: id,
                    timestamp: timestamp,
                    sender: record.sender,
                    subject: record.subject,
                    emailContent: record.emailContent,
                    aiResponse: record.aiResponse,
                    status: status,
                    errorMessage: record.errorMessage,
                    tokens: record.tokens,
                    cost: record.cost,
                    responseTime: record.responseTime,
                    modelId: record.modelId
                )
            }
        }
    }

    nonisolated func deleteEmailLog(id: UUID) {
        try? dbQueue.write { db in
            try db.execute(
                sql: "DELETE FROM email_logs WHERE id = ?",
                arguments: [id.uuidString]
            )
        }
    }

    nonisolated func clearEmailLogs() {
        try? dbQueue.write { db in
            try db.execute(sql: "DELETE FROM email_logs")
        }
    }

    nonisolated func getEmailLogCount() throws -> Int {
        try dbQueue.read { db in
            try EmailLogRecord.fetchCount(db)
        }
    }

    // MARK: - Message Metadata Operations

    nonisolated func getMessageMetadata(messageId: UUID) throws -> MessageMetadataRecord? {
        try dbQueue.read { db in
            try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString)
        }
    }

    nonisolated func setMessageStarred(messageId: UUID, starred: Bool) throws {
        try dbQueue.write { db in
            // Check if metadata exists
            if var existing = try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString) {
                existing.user_starred = starred ? 1 : 0
                try existing.update(db)
            } else {
                // Create new metadata record
                let record = MessageMetadataRecord(
                    message_id: messageId.uuidString,
                    importance_score: 0.0,
                    user_starred: starred ? 1 : 0,
                    summary: nil,
                    chunk_index: 0
                )
                try record.insert(db)
            }
        }
    }

    nonisolated func setMessageImportance(messageId: UUID, score: Double) throws {
        try dbQueue.write { db in
            if var existing = try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString) {
                existing.importance_score = score
                try existing.update(db)
            } else {
                let record = MessageMetadataRecord(
                    message_id: messageId.uuidString,
                    importance_score: score,
                    user_starred: 0,
                    summary: nil,
                    chunk_index: 0
                )
                try record.insert(db)
            }
        }
    }

    nonisolated func getStarredMessages(conversationId: UUID) throws -> [String] {
        try dbQueue.read { db in
            let sql = """
            SELECT mm.message_id
            FROM message_metadata mm
            JOIN messages m ON m.id = mm.message_id
            WHERE m.conversationId = ? AND mm.user_starred = 1
            ORDER BY m.sortOrder
            """
            return try String.fetchAll(db, sql: sql, arguments: [conversationId.uuidString])
        }
    }

    // MARK: - Embedding Operations

    nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
        let now = Self.isoString(from: Date())
        let record = MessageEmbeddingRecord(
            message_id: messageId.uuidString,
            embedding: embedding,
            embedding_model: model,
            embedding_dimension: dimension,
            created_at: now
        )
        try dbQueue.write { db in
            try record.save(db)
        }
    }

    nonisolated func getMessageEmbedding(messageId: UUID) throws -> Data? {
        try dbQueue.read { db in
            try MessageEmbeddingRecord.fetchOne(db, key: messageId.uuidString)?.embedding
        }
    }

    nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
        let now = Self.isoString(from: Date())
        let record = ConversationEmbeddingRecord(
            conversation_id: conversationId.uuidString,
            embedding: embedding,
            embedding_model: model,
            embedding_dimension: dimension,
            created_at: now
        )
        try dbQueue.write { db in
            try record.save(db)
        }
    }

    nonisolated func getConversationEmbedding(conversationId: UUID) throws -> Data? {
        try dbQueue.read { db in
            try ConversationEmbeddingRecord.fetchOne(db, key: conversationId.uuidString)?.embedding
        }
    }

    nonisolated func getAllConversationEmbeddings() throws -> [(UUID, Data)] {
        try dbQueue.read { db in
            let records = try ConversationEmbeddingRecord.fetchAll(db)
            return records.compactMap { record in
                guard let id = UUID(uuidString: record.conversation_id) else { return nil }
                return (id, record.embedding)
            }
        }
    }

    /// Search conversations by semantic similarity
    nonisolated func searchConversationsBySemantic(queryEmbedding: [Float], limit: Int = 10) throws -> [(Conversation, Float)] {
        // Get all conversations
        let allConversations = try listConversations()

        // Get all conversation embeddings
        let embeddingData = try getAllConversationEmbeddings()
        let embeddingMap = Dictionary(uniqueKeysWithValues: embeddingData)

        // Calculate similarity scores
        var results: [(Conversation, Float)] = []
        for conv in allConversations {
            guard let embeddingData = embeddingMap[conv.id] else { continue }

            // Deserialize embedding
            let embedding = deserializeEmbedding(embeddingData)

            // Calculate cosine similarity
            let similarity = EmbeddingService.shared.cosineSimilarity(queryEmbedding, embedding)
            results.append((conv, similarity))
        }

        // Sort by similarity (highest first) and take top N
        results.sort { $0.1 > $1.1 }
        return Array(results.prefix(limit))
    }

    private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
        var embedding: [Float] = []
        embedding.reserveCapacity(data.count / 4)

        for offset in stride(from: 0, to: data.count, by: 4) {
            let bytes = data.subdata(in: offset..<(offset + 4))
            let bitPattern = bytes.withUnsafeBytes { $0.load(as: UInt32.self) }
            let value = Float(bitPattern: UInt32(littleEndian: bitPattern))
            embedding.append(value)
        }

        return embedding
    }

    // MARK: - Conversation Summary Operations

    nonisolated func saveConversationSummary(
        conversationId: UUID,
        startIndex: Int,
        endIndex: Int,
        summary: String,
        model: String?,
        tokenCount: Int?
    ) throws {
        let now = Self.isoString(from: Date())
        let record = ConversationSummaryRecord(
            id: UUID().uuidString,
            conversation_id: conversationId.uuidString,
            start_message_index: startIndex,
            end_message_index: endIndex,
            summary: summary,
            token_count: tokenCount,
            created_at: now,
            summary_model: model
        )
        try dbQueue.write { db in
            try record.insert(db)
        }
    }

    nonisolated func getConversationSummaries(conversationId: UUID) throws -> [ConversationSummaryRecord] {
        try dbQueue.read { db in
            try ConversationSummaryRecord
                .filter(Column("conversation_id") == conversationId.uuidString)
                .order(Column("start_message_index"))
                .fetchAll(db)
        }
    }

    nonisolated func hasSummaryForRange(conversationId: UUID, startIndex: Int, endIndex: Int) throws -> Bool {
        try dbQueue.read { db in
            let count = try ConversationSummaryRecord
                .filter(Column("conversation_id") == conversationId.uuidString)
                .filter(Column("start_message_index") == startIndex)
                .filter(Column("end_message_index") == endIndex)
                .fetchCount(db)
            return count > 0
        }
    }
}
