//
//  DatabaseServiceTests.swift
//  oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen

import Testing
import Foundation
@testable import Confab

@Suite("DatabaseService migrations, against a throwaway in-memory queue")
struct DatabaseServiceMigrationTests {

    @Test("All v1-v8 tables exist after migration")
    func allTablesExist() {
        let db = DatabaseService.makeInMemory()
        let expectedTables = [
            "conversations", "messages", "settings", "command_history",
            "email_logs", "message_metadata", "message_embeddings",
            "conversation_embeddings", "conversation_summaries",
        ]
        for table in expectedTables {
            #expect(db.tableExists(table), "expected table \(table) to exist")
        }
    }

    @Test("v4 adds modelId to messages and primaryModel to conversations")
    func v4AddsModelColumns() {
        let db = DatabaseService.makeInMemory()
        #expect(db.columnNames(in: "messages").contains("modelId"))
        #expect(db.columnNames(in: "conversations").contains("primaryModel"))
    }

    @Test("messages table has the expected v1 columns")
    func messagesTableColumns() {
        let db = DatabaseService.makeInMemory()
        let columns = Set(db.columnNames(in: "messages"))
        let expected: Set<String> = ["id", "conversationId", "role", "content", "tokens", "cost", "timestamp", "sortOrder"]
        #expect(expected.isSubset(of: columns))
    }

    @Test("message_metadata table has the expected v6 columns")
    func messageMetadataColumns() {
        let db = DatabaseService.makeInMemory()
        let columns = Set(db.columnNames(in: "message_metadata"))
        #expect(columns == ["message_id", "importance_score", "user_starred", "summary", "chunk_index"])
    }

    @Test("conversation_summaries table has the expected v8 columns")
    func conversationSummariesColumns() {
        let db = DatabaseService.makeInMemory()
        let columns = Set(db.columnNames(in: "conversation_summaries"))
        #expect(columns == ["id", "conversation_id", "start_message_index", "end_message_index", "summary", "token_count", "created_at", "summary_model"])
    }

    @Test("A nonexistent table reports as absent, not a crash")
    func nonexistentTableReportsAbsent() {
        let db = DatabaseService.makeInMemory()
        #expect(db.tableExists("not_a_real_table") == false)
    }

    @Test("Two in-memory instances are isolated from each other")
    func instancesAreIsolated() throws {
        let dbA = DatabaseService.makeInMemory()
        let dbB = DatabaseService.makeInMemory()

        _ = try dbA.saveConversation(name: "only in A", messages: [Message(role: .user, content: "hi")])

        #expect(try dbA.listConversations().count == 1)
        #expect(try dbB.listConversations().count == 0)
    }
}

@Suite("DatabaseService settings CRUD, against a throwaway in-memory queue")
struct DatabaseServiceSettingsTests {

    @Test("Round-trips a plain setting")
    func roundTripsSetting() {
        let db = DatabaseService.makeInMemory()
        db.setSetting(key: "theme", value: "dark")
        #expect((try? db.loadAllSettings()["theme"]) == "dark")
    }

    @Test("Deletes a setting")
    func deletesSetting() {
        let db = DatabaseService.makeInMemory()
        db.setSetting(key: "temp", value: "1")
        db.deleteSetting(key: "temp")
        #expect((try? db.loadAllSettings()["temp"]) == nil)
    }
}

@Suite("DatabaseService conversation + message persistence, against a throwaway in-memory queue")
struct DatabaseServiceConversationTests {

    @Test("Saving a conversation round-trips its messages via loadConversation")
    func savesAndLoadsConversation() throws {
        let db = DatabaseService.makeInMemory()
        let messages = [
            Message(role: .user, content: "hello"),
            Message(role: .assistant, content: "hi there"),
        ]
        let saved = try db.saveConversation(name: "Test Chat", messages: messages)

        let loaded = try db.loadConversation(id: saved.id)
        #expect(loaded?.0.name == "Test Chat")
        #expect(loaded?.1.map(\.content) == ["hello", "hi there"])
    }

    @Test("System messages are excluded from persistence")
    func systemMessagesExcluded() throws {
        let db = DatabaseService.makeInMemory()
        let messages = [
            Message(role: .system, content: "tool call"),
            Message(role: .user, content: "hello"),
        ]
        let saved = try db.saveConversation(name: "Test", messages: messages)
        let loaded = try db.loadConversation(id: saved.id)
        #expect(loaded?.1.count == 1)
        #expect(loaded?.1.first?.content == "hello")
    }
}

@Suite("DatabaseService usage statistics, against a throwaway in-memory queue")
struct DatabaseServiceUsageStatsTests {

    @Test("Overall stats aggregate tokens, cost, and message count across conversations")
    func overallStatsAggregate() throws {
        let db = DatabaseService.makeInMemory()
        _ = try db.saveConversation(name: "Chat A", messages: [
            Message(role: .user, content: "hi", tokens: 10, modelId: "claude-sonnet"),
            Message(role: .assistant, content: "hello", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
        ])
        _ = try db.saveConversation(name: "Chat B", messages: [
            Message(role: .user, content: "hey", tokens: 5, modelId: "gpt-4"),
            Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, modelId: "gpt-4"),
        ])

        let stats = try db.getOverallUsageStats()
        #expect(stats.totalMessages == 4)
        #expect(stats.totalTokens == 50)
        #expect(stats.hasCostData == true)
        #expect(abs(stats.totalCost - 0.03) < 0.0001)
    }

    @Test("Overall stats report no cost data when no message has a cost")
    func overallStatsNoCostData() throws {
        let db = DatabaseService.makeInMemory()
        _ = try db.saveConversation(name: "Chat", messages: [
            Message(role: .user, content: "hi", tokens: 10),
        ])

        let stats = try db.getOverallUsageStats()
        #expect(stats.hasCostData == false)
        #expect(stats.totalCost == 0)
    }

    @Test("Usage by model groups messages by modelId and sums their tokens/cost")
    func usageByModelGroups() throws {
        let db = DatabaseService.makeInMemory()
        _ = try db.saveConversation(name: "Chat A", messages: [
            Message(role: .assistant, content: "a1", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
        ])
        _ = try db.saveConversation(name: "Chat B", messages: [
            Message(role: .assistant, content: "b1", tokens: 15, cost: 0.02, modelId: "gpt-4"),
            Message(role: .assistant, content: "b2", tokens: 5, cost: 0.02, modelId: "gpt-4"),
        ])

        let byModel = try db.getUsageByModel()
        #expect(byModel.count == 2)

        let gpt4 = byModel.first { $0.modelId == "gpt-4" }
        #expect(gpt4?.messageCount == 2)
        #expect(gpt4?.totalTokens == 20)
        #expect(abs((gpt4?.totalCost ?? 0) - 0.04) < 0.0001)

        // gpt-4 has higher total cost than claude-sonnet, so it should sort first
        #expect(byModel.first?.modelId == "gpt-4")
    }

    @Test("Usage by model excludes messages with no modelId")
    func usageByModelExcludesNilModelId() throws {
        let db = DatabaseService.makeInMemory()
        _ = try db.saveConversation(name: "Chat", messages: [
            Message(role: .user, content: "hi", tokens: 10),
            Message(role: .assistant, content: "hello", tokens: 20, modelId: "claude-sonnet"),
        ])

        let byModel = try db.getUsageByModel()
        #expect(byModel.count == 1)
        #expect(byModel.first?.modelId == "claude-sonnet")
    }

    @Test("Usage by conversation joins conversation names and sorts by cost descending")
    func usageByConversationSortsByCost() throws {
        let db = DatabaseService.makeInMemory()
        _ = try db.saveConversation(name: "Cheap Chat", messages: [
            Message(role: .assistant, content: "a", tokens: 10, cost: 0.001, modelId: "m"),
        ])
        _ = try db.saveConversation(name: "Expensive Chat", messages: [
            Message(role: .assistant, content: "b", tokens: 10, cost: 0.05, modelId: "m"),
        ])

        let byConversation = try db.getUsageByConversation()
        #expect(byConversation.count == 2)
        #expect(byConversation.first?.name == "Expensive Chat")
    }

    @Test("Usage by conversation respects the limit parameter")
    func usageByConversationRespectsLimit() throws {
        let db = DatabaseService.makeInMemory()
        for i in 0..<5 {
            _ = try db.saveConversation(name: "Chat \(i)", messages: [
                Message(role: .assistant, content: "a", tokens: 10, cost: Double(i) * 0.01, modelId: "m"),
            ])
        }

        let byConversation = try db.getUsageByConversation(limit: 3)
        #expect(byConversation.count == 3)
    }
}

@Suite("DatabaseService folders, against a throwaway in-memory queue")
struct DatabaseServiceFolderTests {

    @Test("v9 adds the folders table and folderId to conversations")
    func v9AddsFolderSupport() {
        let db = DatabaseService.makeInMemory()
        #expect(db.tableExists("folders"))
        #expect(db.columnNames(in: "conversations").contains("folderId"))
    }

    @Test("v10 adds parentId to folders")
    func v10AddsParentId() {
        let db = DatabaseService.makeInMemory()
        #expect(db.columnNames(in: "folders").contains("parentId"))
    }

    @Test("v11 adds updatedAt to folders")
    func v11AddsUpdatedAt() {
        let db = DatabaseService.makeInMemory()
        #expect(db.columnNames(in: "folders").contains("updatedAt"))
    }

    @Test("renameFolder bumps updatedAt")
    func renameFolderBumpsUpdatedAt() throws {
        let db = DatabaseService.makeInMemory()
        let folder = try db.createFolder(name: "Work")
        // Round-trip through the DB for the "before" value too, so both sides go through the same
        // fractional-seconds truncation as the "after" read below — comparing a raw in-memory
        // Date() (full precision) against a DB-round-tripped one can flake when both timestamps
        // land in the same millisecond window.
        let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == folder.id })?.updatedAt)

        try db.renameFolder(id: folder.id, name: "Projects")

        let updated = try db.listFolders().first(where: { $0.id == folder.id })
        #expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
    }

    @Test("moveFolder bumps updatedAt")
    func moveFolderBumpsUpdatedAt() throws {
        let db = DatabaseService.makeInMemory()
        let parent = try db.createFolder(name: "Work")
        let child = try db.createFolder(name: "Personal")
        // See renameFolderBumpsUpdatedAt's comment: round-trip through the DB for the "before"
        // value so it's truncated the same way as the "after" read.
        let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == child.id })?.updatedAt)

        try db.moveFolder(id: child.id, toParent: parent.id)

        let updated = try db.listFolders().first(where: { $0.id == child.id })
        #expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
    }

    @Test("upsertSyncedFolder creates a folder that doesn't exist locally yet")
    func upsertSyncedFolderCreatesNew() throws {
        let db = DatabaseService.makeInMemory()
        let id = UUID()
        let createdAt = Date(timeIntervalSince1970: 1_000)
        let updatedAt = Date(timeIntervalSince1970: 2_000)

        try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: updatedAt)

        let folders = try db.listFolders()
        let created = try #require(folders.first(where: { $0.id == id }))
        #expect(created.name == "Work")
        #expect(created.parentId == nil)
        #expect(created.updatedAt == updatedAt)
    }

    @Test("upsertSyncedFolder is a no-op when the local version is the same age or newer")
    func upsertSyncedFolderNoOpWhenLocalNotOlder() throws {
        let db = DatabaseService.makeInMemory()
        let id = UUID()
        let createdAt = Date(timeIntervalSince1970: 1_000)
        let localUpdatedAt = Date(timeIntervalSince1970: 5_000)
        try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)

        // Incoming manifest entry is older than what's already local.
        let staleIncomingUpdatedAt = Date(timeIntervalSince1970: 2_000)
        try db.upsertSyncedFolder(id: id, name: "Renamed Elsewhere", parentId: nil, createdAt: createdAt, updatedAt: staleIncomingUpdatedAt)

        let folders = try db.listFolders()
        let unchanged = try #require(folders.first(where: { $0.id == id }))
        #expect(unchanged.name == "Work")
        #expect(unchanged.updatedAt == localUpdatedAt)
    }

    @Test("upsertSyncedFolder updates name and parent when the incoming version is newer")
    func upsertSyncedFolderUpdatesWhenIncomingNewer() throws {
        let db = DatabaseService.makeInMemory()
        let id = UUID()
        let otherParent = try db.createFolder(name: "Other Parent")
        let createdAt = Date(timeIntervalSince1970: 1_000)
        let localUpdatedAt = Date(timeIntervalSince1970: 2_000)
        try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)

        let newerIncomingUpdatedAt = Date(timeIntervalSince1970: 9_000)
        try db.upsertSyncedFolder(
            id: id, name: "Projects", parentId: otherParent.id, createdAt: createdAt, updatedAt: newerIncomingUpdatedAt
        )

        let folders = try db.listFolders()
        let updated = try #require(folders.first(where: { $0.id == id }))
        #expect(updated.name == "Projects")
        #expect(updated.parentId == otherParent.id)
        #expect(updated.updatedAt == newerIncomingUpdatedAt)
    }

    @Test("createFolder(parentId:) nests the new folder under its parent")
    func createFolderWithParent() throws {
        let db = DatabaseService.makeInMemory()
        let parent = try db.createFolder(name: "Work")
        let child = try db.createFolder(name: "Project A", parentId: parent.id)
        #expect(child.parentId == parent.id)

        let folders = try db.listFolders()
        #expect(folders.first(where: { $0.id == child.id })?.parentId == parent.id)
    }

    @Test("moveFolder reparents a folder")
    func moveFolderReparents() throws {
        let db = DatabaseService.makeInMemory()
        let workFolder = try db.createFolder(name: "Work")
        let personalFolder = try db.createFolder(name: "Personal")

        try db.moveFolder(id: personalFolder.id, toParent: workFolder.id)

        let folders = try db.listFolders()
        #expect(folders.first(where: { $0.id == personalFolder.id })?.parentId == workFolder.id)
    }

    @Test("moveFolder promotes a nested folder to top-level when given nil")
    func moveFolderPromotesToTopLevel() throws {
        let db = DatabaseService.makeInMemory()
        let parent = try db.createFolder(name: "Work")
        let child = try db.createFolder(name: "Project A", parentId: parent.id)

        try db.moveFolder(id: child.id, toParent: nil)

        let folders = try db.listFolders()
        #expect(folders.first(where: { $0.id == child.id })?.parentId == nil)
    }

    @Test("moveFolder throws wouldCreateCycle when reparenting a folder under itself")
    func moveFolderSelfReparentThrows() throws {
        let db = DatabaseService.makeInMemory()
        let folder = try db.createFolder(name: "Work")
        #expect(throws: DatabaseService.FolderError.wouldCreateCycle) {
            try db.moveFolder(id: folder.id, toParent: folder.id)
        }
    }

    @Test("moveFolder throws wouldCreateCycle when reparenting an ancestor under its own descendant")
    func moveFolderAncestorUnderDescendantThrows() throws {
        let db = DatabaseService.makeInMemory()
        let grandparent = try db.createFolder(name: "Work")
        let parent = try db.createFolder(name: "Project A", parentId: grandparent.id)
        let child = try db.createFolder(name: "Sub-task", parentId: parent.id)

        #expect(throws: DatabaseService.FolderError.wouldCreateCycle) {
            try db.moveFolder(id: grandparent.id, toParent: child.id)
        }
    }

    @Test("Creating folders assigns increasing sort order")
    func createFolderAssignsSortOrder() throws {
        let db = DatabaseService.makeInMemory()
        let first = try db.createFolder(name: "Work")
        let second = try db.createFolder(name: "Personal")
        #expect(first.sortOrder == 0)
        #expect(second.sortOrder == 1)
    }

    @Test("listFolders returns folders sorted alphabetically, case-insensitive, regardless of creation order")
    func listFoldersOrdered() throws {
        let db = DatabaseService.makeInMemory()
        _ = try db.createFolder(name: "Work")
        _ = try db.createFolder(name: "apple")
        _ = try db.createFolder(name: "Personal")

        let folders = try db.listFolders()
        #expect(folders.map(\.name) == ["apple", "Personal", "Work"])
    }

    @Test("renameFolder updates the stored name")
    func renameFolderUpdatesName() throws {
        let db = DatabaseService.makeInMemory()
        let folder = try db.createFolder(name: "Work")
        try db.renameFolder(id: folder.id, name: "Projects")

        let folders = try db.listFolders()
        #expect(folders.first?.name == "Projects")
    }

    @Test("moveConversation files a conversation into a folder")
    func moveConversationFiles() throws {
        let db = DatabaseService.makeInMemory()
        let folder = try db.createFolder(name: "Work")
        let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])

        try db.moveConversation(id: conversation.id, toFolder: folder.id)

        let loaded = try db.loadConversation(id: conversation.id)
        #expect(loaded?.0.folderId == folder.id)
    }

    @Test("moveConversation with nil folder unfiles a conversation")
    func moveConversationUnfiles() throws {
        let db = DatabaseService.makeInMemory()
        let folder = try db.createFolder(name: "Work")
        let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
        try db.moveConversation(id: conversation.id, toFolder: folder.id)

        try db.moveConversation(id: conversation.id, toFolder: nil)

        let loaded = try db.loadConversation(id: conversation.id)
        #expect(loaded?.0.folderId == nil)
    }

    @Test("Deleting a folder unfiles its conversations instead of deleting them")
    func deleteFolderUnfilesConversations() throws {
        let db = DatabaseService.makeInMemory()
        let folder = try db.createFolder(name: "Work")
        let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
        try db.moveConversation(id: conversation.id, toFolder: folder.id)

        try db.deleteFolder(id: folder.id)

        let folders = try db.listFolders()
        #expect(folders.isEmpty)

        let loaded = try db.loadConversation(id: conversation.id)
        #expect(loaded != nil)
        #expect(loaded?.0.folderId == nil)
    }

    @Test("Deleting a nested folder reparents its children and conversations up one level, not to top-level")
    func deleteNestedFolderReparentsUpOneLevel() throws {
        let db = DatabaseService.makeInMemory()
        let work = try db.createFolder(name: "Work")
        let projectA = try db.createFolder(name: "Project A", parentId: work.id)
        let subTask = try db.createFolder(name: "Sub-task", parentId: projectA.id)
        let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
        try db.moveConversation(id: conversation.id, toFolder: projectA.id)

        try db.deleteFolder(id: projectA.id)

        let folders = try db.listFolders()
        #expect(folders.first(where: { $0.id == subTask.id })?.parentId == work.id)
        #expect(folders.contains(where: { $0.id == projectA.id }) == false)

        let loaded = try db.loadConversation(id: conversation.id)
        #expect(loaded?.0.folderId == work.id)
    }

    @Test("listConversations reflects folderId")
    func listConversationsReflectsFolderId() throws {
        let db = DatabaseService.makeInMemory()
        let folder = try db.createFolder(name: "Work")
        let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
        try db.moveConversation(id: conversation.id, toFolder: folder.id)

        let conversations = try db.listConversations()
        #expect(conversations.first?.folderId == folder.id)
    }
}

@Suite("DatabaseService per-conversation notes (v12), against a throwaway in-memory queue")
struct DatabaseServiceNotesTests {

    @Test("conversations table has notesEnabled and notesFilename columns after v12")
    func v12AddsNotesColumns() {
        let db = DatabaseService.makeInMemory()
        let columns = Set(db.columnNames(in: "conversations"))
        #expect(columns.contains("notesEnabled"))
        #expect(columns.contains("notesFilename"))
    }

    @Test("A newly saved conversation defaults to notes disabled with no filename")
    func newConversationDefaultsToNotesDisabled() throws {
        let db = DatabaseService.makeInMemory()
        let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
        #expect(saved.notesEnabled == false)
        #expect(saved.notesFilename == nil)

        let loaded = try db.loadConversation(id: saved.id)
        #expect(loaded?.0.notesEnabled == false)
        #expect(loaded?.0.notesFilename == nil)
    }

    @Test("setNotesEnabled persists and round-trips through loadConversation")
    func setNotesEnabledRoundTrips() throws {
        let db = DatabaseService.makeInMemory()
        let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])

        try db.setNotesEnabled(id: saved.id, enabled: true)

        let loaded = try db.loadConversation(id: saved.id)
        #expect(loaded?.0.notesEnabled == true)
    }

    @Test("setNotesFilename persists and round-trips through loadConversation")
    func setNotesFilenameRoundTrips() throws {
        let db = DatabaseService.makeInMemory()
        let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])

        try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")

        let loaded = try db.loadConversation(id: saved.id)
        #expect(loaded?.0.notesFilename == "Chat-a3f2.md")
    }

    @Test("listConversations reflects notesEnabled and notesFilename")
    func listConversationsReflectsNotes() throws {
        let db = DatabaseService.makeInMemory()
        let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
        try db.setNotesEnabled(id: saved.id, enabled: true)
        try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")

        let conversations = try db.listConversations()
        #expect(conversations.first?.notesEnabled == true)
        #expect(conversations.first?.notesFilename == "Chat-a3f2.md")
    }
}
