package net.sergeych.toread.storage.jdbc

import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.NoteRecord
import net.sergeych.toread.storage.ReadingStateRecord
import java.nio.file.Files
import java.sql.DriverManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

class H2LibraryDatabaseTest {
    @Test
    fun storesCatalogFileAndUserData() {
        val db = H2LibraryDatabase.openMemory("storesCatalogFileAndUserData")
        val now = 1_700_000_000_000L

        db.transaction {
            books.upsert(
                BookRecord(
                    id = "book-1",
                    title = "Example",
                    authors = listOf("Jane Example"),
                    language = "en",
                    date = "2024",
                    coverImage = byteArrayOf(1, 2, 3),
                    coverImageMimeType = "image/jpeg",
                    createdAt = now,
                    updatedAt = now,
                )
            )
            bodies.upsert(
                BookBodyRecord(
                    id = "body-1",
                    exactTextHash = "text-sha",
                    canonicalizationVersion = 1,
                    nearSignature = "near",
                    wordCount = 42,
                    language = "en",
                    createdAt = now,
                )
            )
            clusters.upsert(
                BodyClusterRecord(
                    id = "cluster-1",
                    representativeBodyId = "body-1",
                    createdAt = now,
                )
            )
            files.upsert(
                BookFileRecord(
                    id = "file-1",
                    bookId = "book-1",
                    bodyId = "body-1",
                    bodyClusterId = "cluster-1",
                    rawSha256 = "raw-sha",
                    format = "fb2",
                    mimeType = "application/x-fictionbook+xml",
                    sizeBytes = 1024,
                    originalFilename = "example.fb2",
                    storageKind = BookFileStorageKind.EXTERNAL_URI,
                    storageUri = "/books/example.fb2",
                    lastSeenAt = now,
                    createdAt = now,
                    updatedAt = now,
                )
            )
            val anchor = ContentAnchor(
                canonicalCharOffset = 120,
                canonicalTokenOffset = 30,
                progress = 0.25,
                exact = "selected text",
                prefix = "before",
                suffix = "after",
            )
            readingStates.upsert(
                ReadingStateRecord(
                    id = "reading-1",
                    bodyClusterId = "cluster-1",
                    bodyId = "body-1",
                    anchor = anchor,
                    updatedAt = now + 1,
                )
            )
            bookmarks.upsert(
                BookmarkRecord(
                    id = "bookmark-1",
                    bodyClusterId = "cluster-1",
                    bodyId = "body-1",
                    anchor = anchor,
                    title = "Important",
                    selectedTextSnapshot = "selected text",
                    createdAt = now + 2,
                    updatedAt = now + 2,
                )
            )
            notes.upsert(
                NoteRecord(
                    id = "note-1",
                    bookmarkId = "bookmark-1",
                    bodyClusterId = "cluster-1",
                    bodyId = "body-1",
                    anchor = anchor,
                    text = "Remember this",
                    createdAt = now + 3,
                    updatedAt = now + 3,
                )
            )
        }

        assertEquals("Example", db.books.get("book-1")?.title)
        assertEquals("Jane Example", db.files.listLibraryFiles().single().authors.single())
        assertEquals("2024", db.files.getLibraryFile("file-1")?.date)
        assertEquals("image/jpeg", db.books.getCover("book-1")?.mimeType)
        assertEquals(BookReadingStatus.NEW, db.files.getLibraryFile("file-1")?.readingStatus)
        assertEquals(false, db.files.getLibraryFile("file-1")?.favorite)
        assertEquals(true, db.files.updateFavorite("file-1", true))
        assertEquals(true, db.files.getLibraryFile("file-1")?.favorite)
        assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.READING))
        assertEquals(BookReadingStatus.READING, db.files.getLibraryFile("file-1")?.readingStatus)
        assertEquals("body-1", db.bodies.findByExactTextHash("text-sha", 1)?.id)
        assertEquals("cluster-1", db.clusters.get("cluster-1")?.id)
        assertEquals(BookFileStorageKind.EXTERNAL_URI, db.files.get("file-1")?.storageKind)
        assertEquals(1, db.files.findByRawSha256("raw-sha").size)
        assertEquals(0.25, db.readingStates.getForBodyCluster("cluster-1")?.anchor?.progress)
        assertEquals("Important", db.bookmarks.listForBodyCluster("cluster-1").single().title)
        assertEquals("Remember this", db.notes.listForBookmark("bookmark-1").single().text)

        db.close()
    }

    @Test
    fun transactionRollsBackOnFailure() {
        val db = H2LibraryDatabase.openMemory("transactionRollsBackOnFailure")
        val error = runCatching {
            db.transaction {
                books.upsert(
                    BookRecord(
                        id = "book-rollback",
                        title = "Rollback",
                        createdAt = 1,
                        updatedAt = 1,
                    )
                )
                error("boom")
            }
        }.exceptionOrNull()

        assertNotNull(error)
        assertNull(db.books.get("book-rollback"))
        db.close()
    }

    @Test
    fun listsLibraryFilesByNaturalTitle() {
        val db = H2LibraryDatabase.openMemory("listsLibraryFilesByNaturalTitle")
        val now = 1_700_000_000_000L

        db.transaction {
            listOf(
                Triple("book-10", "Book 10", BookReadingStatus.READING),
                Triple("book-alpha", "Alpha", BookReadingStatus.READ),
                Triple("book-2", "Book 2", BookReadingStatus.NEW),
                Triple("book-beta", "Beta", BookReadingStatus.NOT_INTERESTED),
            ).forEachIndexed { index, (bookId, title, status) ->
                books.upsert(
                    BookRecord(
                        id = bookId,
                        title = title,
                        createdAt = now + index,
                        updatedAt = now + index,
                    )
                )
                files.upsert(
                    BookFileRecord(
                        id = "file-$title",
                        bookId = bookId,
                        rawSha256 = "sha-$title",
                        originalFilename = "$title.fb2",
                        storageKind = BookFileStorageKind.EXTERNAL_URI,
                        readingStatus = status,
                        lastReadAt = now + index,
                        createdAt = now + index,
                        updatedAt = now + index,
                    )
                )
            }
        }

        assertEquals(
            listOf("Alpha", "Beta", "Book 2", "Book 10"),
            db.files.listLibraryFiles().map { it.title },
        )
        db.close()
    }

    @Test
    fun listsRecentlyAddedNewLibraryFiles() {
        val db = H2LibraryDatabase.openMemory("listsRecentlyAddedNewLibraryFiles")
        val now = 1_700_000_000_000L

        db.transaction {
            listOf(
                Triple("book-old", "Old", BookReadingStatus.NEW),
                Triple("book-newer", "Newer", BookReadingStatus.NEW),
                Triple("book-newest", "Newest", BookReadingStatus.NEW),
                Triple("book-read", "Read", BookReadingStatus.READ),
            ).forEachIndexed { index, (bookId, title, status) ->
                books.upsert(BookRecord(id = bookId, title = title, createdAt = now + index, updatedAt = now + index))
                files.upsert(
                    BookFileRecord(
                        id = "file-$title",
                        bookId = bookId,
                        rawSha256 = "sha-$title",
                        originalFilename = "$title.fb2",
                        storageKind = BookFileStorageKind.EXTERNAL_URI,
                        readingStatus = status,
                        importedAt = when (title) {
                            "Old" -> now - 1_000
                            "Newer" -> now + 2_000
                            "Newest" -> now + 3_000
                            else -> now + 4_000
                        },
                        createdAt = now + index,
                        updatedAt = now + index,
                    )
                )
            }
        }

        assertEquals(
            listOf("Newest", "Newer"),
            db.files.listRecentlyAddedLibraryFiles(now, 50).map { it.title },
        )
        db.close()
    }

    @Test
    fun hidesDuplicateLibraryFilesAndPrefersZip() {
        val db = H2LibraryDatabase.openMemory("hidesDuplicateLibraryFilesAndPrefersZip")
        val now = 1_700_000_000_000L

        db.transaction {
            bodies.upsert(
                BookBodyRecord(
                    id = "body-dupe",
                    exactTextHash = "same-text",
                    canonicalizationVersion = 1,
                    createdAt = now,
                )
            )
            clusters.upsert(
                BodyClusterRecord(
                    id = "cluster-dupe",
                    representativeBodyId = "body-dupe",
                    createdAt = now,
                )
            )
            books.upsert(BookRecord(id = "book-fb2", title = "Same Book", createdAt = now, updatedAt = now))
            books.upsert(BookRecord(id = "book-zip", title = "Same Book", createdAt = now, updatedAt = now + 1))
            files.upsert(
                BookFileRecord(
                    id = "file-fb2",
                    bookId = "book-fb2",
                    bodyId = "body-dupe",
                    bodyClusterId = "cluster-dupe",
                    rawSha256 = "raw-fb2",
                    format = "fb2",
                    originalFilename = "same.fb2",
                    storageKind = BookFileStorageKind.EXTERNAL_URI,
                    createdAt = now,
                    updatedAt = now + 10,
                )
            )
            files.upsert(
                BookFileRecord(
                    id = "file-zip",
                    bookId = "book-zip",
                    bodyId = "body-dupe",
                    bodyClusterId = "cluster-dupe",
                    rawSha256 = "raw-zip",
                    format = "fb2.zip",
                    originalFilename = "same.fb2.zip",
                    storageKind = BookFileStorageKind.EXTERNAL_URI,
                    createdAt = now,
                    updatedAt = now,
                )
            )
        }

        assertEquals(listOf("file-zip"), db.files.listLibraryFiles().map { it.fileId })
        assertEquals(true, db.files.updateReadingStatus("file-zip", BookReadingStatus.READING))
        assertEquals(BookReadingStatus.READING, db.files.get("file-fb2")?.readingStatus)
        assertEquals(BookReadingStatus.READING, db.files.get("file-zip")?.readingStatus)
        assertEquals(true, db.files.updateFavorite("file-zip", true))
        assertEquals(true, db.files.get("file-fb2")?.favorite)
        assertEquals(true, db.files.get("file-zip")?.favorite)
        db.close()
    }

    @Test
    fun searchesLibraryFilesByPrefixesAndRanksByHitCountThenDistance() {
        val db = H2LibraryDatabase.openMemory("searchesLibraryFilesByPrefixesAndRanksByHitCountThenDistance")
        val now = 1_700_000_000_000L

        db.transaction {
            books.upsert(
                BookRecord(
                    id = "book-close",
                    title = "red blue alpha",
                    authors = listOf("Jane Example"),
                    createdAt = now,
                    updatedAt = now,
                )
            )
            books.upsert(
                BookRecord(
                    id = "book-far",
                    title = "red",
                    description = "alpha",
                    createdAt = now,
                    updatedAt = now,
                )
            )
            books.upsert(
                BookRecord(
                    id = "book-keyword",
                    title = "Unrelated",
                    keywords = "nebula archive",
                    createdAt = now,
                    updatedAt = now,
                )
            )
            books.upsert(
                BookRecord(
                    id = "book-ruso",
                    title = "El Ruso",
                    createdAt = now,
                    updatedAt = now,
                )
            )
            listOf(
                "file-close" to "book-close",
                "file-far" to "book-far",
                "file-keyword" to "book-keyword",
                "file-ruso" to "book-ruso",
            ).forEachIndexed { index, (fileId, bookId) ->
                files.upsert(
                    BookFileRecord(
                        id = fileId,
                        bookId = bookId,
                        rawSha256 = "sha-$fileId",
                        storageKind = BookFileStorageKind.EXTERNAL_URI,
                        createdAt = now + index,
                        updatedAt = now + index,
                    )
                )
            }
        }

        assertEquals(
            listOf("file-close", "file-far"),
            db.files.searchLibraryFiles(listOf("red", "alp")).map { it.fileId },
        )
        assertEquals(
            listOf("file-close"),
            db.files.searchLibraryFiles(listOf("blu")).map { it.fileId },
        )
        assertEquals(
            listOf("file-keyword"),
            db.files.searchLibraryFiles(listOf("neb")).map { it.fileId },
        )
        assertEquals(
            listOf("file-ruso"),
            db.files.searchLibraryFiles(listOf("rus")).map { it.fileId },
        )
        assertEquals(
            emptyList(),
            db.files.searchLibraryFiles(listOf("ous")).map { it.fileId },
        )
        db.close()
    }

    @Test
    fun opensDatabaseWithExistingUppercaseIndex() {
        val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()

        Class.forName("org.h2.Driver")
        DriverManager.getConnection("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", "sa", "").use { connection ->
            connection.createStatement().use { statement ->
                statement.execute(
                    """
                    CREATE TABLE book_bodies (
                        id VARCHAR PRIMARY KEY,
                        exact_text_hash VARCHAR NOT NULL,
                        canonicalization_version INT NOT NULL,
                        near_signature VARCHAR,
                        word_count INT,
                        language VARCHAR,
                        created_at BIGINT NOT NULL
                    )
                    """.trimIndent()
                )
                statement.execute(
                    """
                    CREATE UNIQUE INDEX IDX_BOOK_BODIES_EXACT
                    ON book_bodies(exact_text_hash, canonicalization_version)
                    """.trimIndent()
                )
            }
        }

        H2LibraryDatabase.openFile(path).close()
    }

    @Test
    fun persistsReadingStatusAcrossDatabaseRestart() {
        val path = Files.createTempDirectory("toread-h2-reading-status-").resolve("library").toString()
        val now = 1_700_000_000_000L

        val initialDb = H2LibraryDatabase.openFile(path)
        try {
            val db = initialDb
            db.transaction {
                books.upsert(BookRecord(id = "book-1", title = "Persistent", createdAt = now, updatedAt = now))
                files.upsert(
                    BookFileRecord(
                        id = "file-1",
                        bookId = "book-1",
                        rawSha256 = "sha-1",
                        storageKind = BookFileStorageKind.EXTERNAL_URI,
                        createdAt = now,
                        updatedAt = now,
                    )
                )
            }
            assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.NOT_INTERESTED))
        } finally {
            initialDb.close()
        }

        val reopenedDb = H2LibraryDatabase.openFile(path)
        try {
            val db = reopenedDb
            assertEquals(BookReadingStatus.NOT_INTERESTED, db.files.get("file-1")?.readingStatus)
            assertEquals(BookReadingStatus.NOT_INTERESTED, db.files.getLibraryFile("file-1")?.readingStatus)
        } finally {
            reopenedDb.close()
        }
    }

    @Test
    fun migratesLegacyBookFilesWithImportTime() {
        val path = Files.createTempDirectory("toread-h2-imported-at-").resolve("library").toString()

        Class.forName("org.h2.Driver")
        DriverManager.getConnection("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", "sa", "").use { connection ->
            connection.createStatement().use { statement ->
                statement.execute(
                    """
                    CREATE TABLE book_files (
                        id VARCHAR PRIMARY KEY,
                        book_id VARCHAR,
                        body_id VARCHAR,
                        body_cluster_id VARCHAR,
                        duplicate_of_file_id VARCHAR,
                        raw_sha256 VARCHAR NOT NULL,
                        format VARCHAR,
                        mime_type VARCHAR,
                        size_bytes BIGINT,
                        original_filename VARCHAR,
                        storage_kind VARCHAR NOT NULL,
                        storage_uri VARCHAR,
                        content_object_id VARCHAR,
                        last_modified_millis BIGINT,
                        last_seen_at BIGINT,
                        reading_status VARCHAR NOT NULL DEFAULT 'NEW',
                        last_read_at BIGINT,
                        created_at BIGINT NOT NULL,
                        updated_at BIGINT NOT NULL
                    )
                    """.trimIndent()
                )
                statement.execute(
                    """
                    INSERT INTO book_files(
                        id, raw_sha256, storage_kind, reading_status, created_at, updated_at
                    ) VALUES('legacy-file', 'legacy-sha', 'EXTERNAL_URI', 'NEW', 1, 2)
                    """.trimIndent()
                )
            }
        }

        val beforeMigration = System.currentTimeMillis()
        val db = H2LibraryDatabase.openFile(path)
        val file = assertNotNull(db.files.get("legacy-file"))

        assertTrue(file.importedAt >= beforeMigration)
        assertTrue(file.importedAt <= System.currentTimeMillis())
        db.close()
    }
}
