package net.sergeych.toread.storage.jdbc

import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BodyClusterRepository
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookBodyRepository
import net.sergeych.toread.storage.BookCoverRecord
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileRepository
import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookRepository
import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.BookmarkRepository
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryDatabase
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.NoteRecord
import net.sergeych.toread.storage.NoteRepository
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.ReadingStateRepository
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.SQLException
import java.util.concurrent.ConcurrentHashMap

class H2LibraryDatabase private constructor(
    private val connection: Connection,
    migrate: Boolean,
) : LibraryDatabase {
    override val books: BookRepository = JdbcBookRepository(connection)
    override val bodies: BookBodyRepository = JdbcBookBodyRepository(connection)
    override val clusters: BodyClusterRepository = JdbcBodyClusterRepository(connection)
    override val files: BookFileRepository = JdbcBookFileRepository(connection)
    override val readingStates: ReadingStateRepository = JdbcReadingStateRepository(connection)
    override val bookmarks: BookmarkRepository = JdbcBookmarkRepository(connection)
    override val notes: NoteRepository = JdbcNoteRepository(connection)

    init {
        connection.createStatement().use { statement ->
            statement.execute("SET LOCK_TIMEOUT 10000")
        }
        if (migrate) migrate(connection)
    }

    override fun <T> transaction(block: LibraryDatabase.() -> T): T {
        val previousAutoCommit = connection.autoCommit
        connection.autoCommit = false
        return try {
            val result = block(this)
            connection.commit()
            result
        } catch (t: Throwable) {
            connection.rollback()
            throw t
        } finally {
            connection.autoCommit = previousAutoCommit
        }
    }

    override fun close() {
        connection.close()
    }

    fun shutdownCompact() {
        connection.createStatement().use { it.execute("SHUTDOWN COMPACT") }
    }

    fun getAppFlag(key: String): String? =
        connection.selectOne("SELECT flag_value FROM app_flags WHERE flag_key = ?", key) {
            it.getString("flag_value")
        }

    fun setAppFlag(key: String, value: String?) {
        if (value == null) {
            connection.prepareStatement("DELETE FROM app_flags WHERE flag_key = ?").use { statement ->
                statement.setString(1, key)
                statement.executeUpdate()
            }
        } else {
            connection.prepareStatement(
                """
                MERGE INTO app_flags(flag_key, flag_value, updated_at)
                KEY(flag_key) VALUES(?, ?, ?)
                """.trimIndent()
            ).use { statement ->
                statement.setString(1, key)
                statement.setString(2, value)
                statement.setLong(3, System.currentTimeMillis())
                statement.executeUpdate()
            }
        }
    }

    companion object {
        private val openLocks = ConcurrentHashMap<String, Any>()
        private val migratedUrls = ConcurrentHashMap.newKeySet<String>()

        fun openFile(path: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
            return openUrl("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", user, password)
        }

        fun openMemory(name: String = "toread", user: String = "sa", password: String = ""): H2LibraryDatabase {
            return openUrl("jdbc:h2:mem:$name;DB_CLOSE_DELAY=-1", user, password)
        }

        fun openUrl(url: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
            Class.forName("org.h2.Driver")
            val lock = openLocks.computeIfAbsent(url) { Any() }
            synchronized(lock) {
                val shouldMigrate = !migratedUrls.contains(url)
                val database = H2LibraryDatabase(DriverManager.getConnection(url, user, password), shouldMigrate)
                if (shouldMigrate) migratedUrls += url
                return database
            }
        }
    }
}

private const val SchemaVersion = 1

private fun migrate(connection: Connection) {
    connection.createStatement().use { statement ->
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS library_schema (
                id INT PRIMARY KEY,
                version INT NOT NULL
            )
            """.trimIndent()
        )
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS app_flags (
                flag_key VARCHAR PRIMARY KEY,
                flag_value CLOB,
                updated_at BIGINT NOT NULL
            )
            """.trimIndent()
        )
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS books (
                id VARCHAR PRIMARY KEY,
                title VARCHAR,
                subtitle VARCHAR,
                authors CLOB,
                language VARCHAR,
                published_date VARCHAR,
                description CLOB,
                keywords CLOB,
                cover_image BLOB,
                cover_image_mime_type VARCHAR,
                created_at BIGINT NOT NULL,
                updated_at BIGINT NOT NULL
            )
            """.trimIndent()
        )
        statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS authors CLOB")
        statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS published_date VARCHAR")
        statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS keywords CLOB")
        statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image BLOB")
        statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR")
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS 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()
        )
        connection.createIndexIfMissing(
            "idx_book_bodies_exact",
            """
            CREATE UNIQUE INDEX IF NOT EXISTS idx_book_bodies_exact
            ON book_bodies(exact_text_hash, canonicalization_version)
            """.trimIndent()
        )
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS body_clusters (
                id VARCHAR PRIMARY KEY,
                representative_body_id VARCHAR NOT NULL,
                created_at BIGINT NOT NULL,
                FOREIGN KEY (representative_body_id) REFERENCES book_bodies(id)
            )
            """.trimIndent()
        )
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS 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',
                favorite BOOLEAN NOT NULL DEFAULT FALSE,
                last_read_at BIGINT,
                imported_at BIGINT NOT NULL,
                created_at BIGINT NOT NULL,
                updated_at BIGINT NOT NULL,
                FOREIGN KEY (book_id) REFERENCES books(id),
                FOREIGN KEY (body_id) REFERENCES book_bodies(id),
                FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id)
            )
            """.trimIndent()
        )
        statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR")
        statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
        statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS favorite BOOLEAN NOT NULL DEFAULT FALSE")
        statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
        statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS imported_at BIGINT")
        connection.prepareStatement("UPDATE book_files SET imported_at = ? WHERE imported_at IS NULL").use { update ->
            update.setLong(1, System.currentTimeMillis())
            update.executeUpdate()
        }
        statement.execute("ALTER TABLE book_files ALTER COLUMN imported_at SET NOT NULL")
        connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
        connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
        connection.createIndexIfMissing("idx_book_files_duplicate", "CREATE INDEX IF NOT EXISTS idx_book_files_duplicate ON book_files(duplicate_of_file_id)")
        connection.createIndexIfMissing("idx_book_files_name_size_mtime", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_mtime ON book_files(original_filename, size_bytes, last_modified_millis)")
        connection.createIndexIfMissing("idx_book_files_name_size_hash", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_hash ON book_files(original_filename, size_bytes, raw_sha256)")
        connection.createIndexIfMissing("idx_book_files_updated_at", "CREATE INDEX IF NOT EXISTS idx_book_files_updated_at ON book_files(updated_at)")
        connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)")
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS reading_states (
                id VARCHAR PRIMARY KEY,
                body_cluster_id VARCHAR NOT NULL,
                body_id VARCHAR,
                anchor_version INT NOT NULL,
                canonical_char_offset BIGINT,
                canonical_token_offset BIGINT,
                progress DOUBLE PRECISION,
                exact_text CLOB,
                prefix_text CLOB,
                suffix_text CLOB,
                fingerprints_json CLOB,
                format_hints_json CLOB,
                updated_at BIGINT NOT NULL,
                FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id),
                FOREIGN KEY (body_id) REFERENCES book_bodies(id)
            )
            """.trimIndent()
        )
        connection.createIndexIfMissing("idx_reading_states_cluster", "CREATE INDEX IF NOT EXISTS idx_reading_states_cluster ON reading_states(body_cluster_id)")
        statement.execute(
            """
            UPDATE book_files
            SET reading_status = 'READING',
                last_read_at = (
                    SELECT MAX(updated_at)
                    FROM reading_states
                    WHERE reading_states.body_cluster_id = book_files.body_cluster_id
                )
            WHERE reading_status = 'NEW'
              AND last_read_at IS NULL
              AND body_cluster_id IN (SELECT body_cluster_id FROM reading_states)
            """.trimIndent()
        )
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS bookmarks (
                id VARCHAR PRIMARY KEY,
                body_cluster_id VARCHAR NOT NULL,
                body_id VARCHAR,
                anchor_version INT NOT NULL,
                canonical_char_offset BIGINT,
                canonical_token_offset BIGINT,
                progress DOUBLE PRECISION,
                exact_text CLOB,
                prefix_text CLOB,
                suffix_text CLOB,
                fingerprints_json CLOB,
                format_hints_json CLOB,
                title VARCHAR,
                selected_text_snapshot CLOB,
                color VARCHAR,
                created_at BIGINT NOT NULL,
                updated_at BIGINT NOT NULL,
                FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id),
                FOREIGN KEY (body_id) REFERENCES book_bodies(id)
            )
            """.trimIndent()
        )
        connection.createIndexIfMissing("idx_bookmarks_cluster", "CREATE INDEX IF NOT EXISTS idx_bookmarks_cluster ON bookmarks(body_cluster_id)")
        statement.execute(
            """
            CREATE TABLE IF NOT EXISTS notes (
                id VARCHAR PRIMARY KEY,
                bookmark_id VARCHAR,
                body_cluster_id VARCHAR NOT NULL,
                body_id VARCHAR,
                anchor_version INT NOT NULL,
                canonical_char_offset BIGINT,
                canonical_token_offset BIGINT,
                progress DOUBLE PRECISION,
                exact_text CLOB,
                prefix_text CLOB,
                suffix_text CLOB,
                fingerprints_json CLOB,
                format_hints_json CLOB,
                text CLOB NOT NULL,
                created_at BIGINT NOT NULL,
                updated_at BIGINT NOT NULL,
                FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id),
                FOREIGN KEY (body_cluster_id) REFERENCES body_clusters(id),
                FOREIGN KEY (body_id) REFERENCES book_bodies(id)
            )
            """.trimIndent()
        )
        connection.createIndexIfMissing("idx_notes_bookmark", "CREATE INDEX IF NOT EXISTS idx_notes_bookmark ON notes(bookmark_id)")
        connection.createIndexIfMissing("idx_notes_cluster", "CREATE INDEX IF NOT EXISTS idx_notes_cluster ON notes(body_cluster_id)")
        statement.execute(
            """
            MERGE INTO library_schema(id, version)
            KEY(id) VALUES(1, $SchemaVersion)
            """.trimIndent()
        )
    }
}

private fun Connection.createIndexIfMissing(indexName: String, createSql: String) {
    if (indexExists(indexName)) return

    createStatement().use { statement ->
        try {
            statement.execute(createSql)
        } catch (e: SQLException) {
            if (!indexExists(indexName)) throw e
        }
    }
}

private fun Connection.indexExists(indexName: String): Boolean {
    return prepareStatement(
        """
        SELECT 1
        FROM INFORMATION_SCHEMA.INDEXES
        WHERE UPPER(INDEX_NAME) = UPPER(?)
        """.trimIndent()
    ).use { statement ->
        statement.setString(1, indexName)
        statement.executeQuery().use { resultSet -> resultSet.next() }
    }
}

private class JdbcBookRepository(private val connection: Connection) : BookRepository {
    override fun upsert(book: BookRecord) {
        connection.prepareStatement(
            """
            MERGE INTO books(
                id, title, subtitle, authors, language, published_date, description, keywords,
                cover_image, cover_image_mime_type, created_at, updated_at
            )
            KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, book.id)
            statement.setStringOrNull(2, book.title)
            statement.setStringOrNull(3, book.subtitle)
            statement.setStringOrNull(4, book.authors.toDbList())
            statement.setStringOrNull(5, book.language)
            statement.setStringOrNull(6, book.date)
            statement.setStringOrNull(7, book.description)
            statement.setStringOrNull(8, book.keywords)
            statement.setBytesOrNull(9, book.coverImage)
            statement.setStringOrNull(10, book.coverImageMimeType)
            statement.setLong(11, book.createdAt)
            statement.setLong(12, book.updatedAt)
            statement.executeUpdate()
        }
    }

    override fun get(id: String): BookRecord? {
        return connection.selectOne("SELECT * FROM books WHERE id = ?", id) { it.toBookRecord() }
    }

    override fun getCover(id: String): BookCoverRecord? {
        return connection.prepareStatement("SELECT cover_image, cover_image_mime_type FROM books WHERE id = ?").use { statement ->
            statement.setString(1, id)
            statement.executeQuery().use { resultSet ->
                if (!resultSet.next()) return@use null
                val image = resultSet.getBytes("cover_image") ?: return@use null
                BookCoverRecord(image = image, mimeType = resultSet.getString("cover_image_mime_type"))
            }
        }
    }

    override fun list(limit: Int, offset: Int): List<BookRecord> {
        return connection.prepareStatement("SELECT * FROM books ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
            statement.setInt(1, limit)
            statement.setInt(2, offset)
            statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookRecord() } }
        }
    }

    override fun delete(id: String): Boolean = connection.deleteById("books", id)
}

private class JdbcBookBodyRepository(private val connection: Connection) : BookBodyRepository {
    override fun upsert(body: BookBodyRecord) {
        connection.prepareStatement(
            """
            MERGE INTO book_bodies(
                id, exact_text_hash, canonicalization_version, near_signature, word_count, language, created_at
            )
            KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, body.id)
            statement.setString(2, body.exactTextHash)
            statement.setInt(3, body.canonicalizationVersion)
            statement.setStringOrNull(4, body.nearSignature)
            statement.setIntOrNull(5, body.wordCount)
            statement.setStringOrNull(6, body.language)
            statement.setLong(7, body.createdAt)
            statement.executeUpdate()
        }
    }

    override fun get(id: String): BookBodyRecord? {
        return connection.selectOne("SELECT * FROM book_bodies WHERE id = ?", id) { it.toBookBodyRecord() }
    }

    override fun findByExactTextHash(exactTextHash: String, canonicalizationVersion: Int): BookBodyRecord? {
        return connection.prepareStatement(
            "SELECT * FROM book_bodies WHERE exact_text_hash = ? AND canonicalization_version = ?"
        ).use { statement ->
            statement.setString(1, exactTextHash)
            statement.setInt(2, canonicalizationVersion)
            statement.executeQuery().use { resultSet ->
                if (resultSet.next()) resultSet.toBookBodyRecord() else null
            }
        }
    }
}

private class JdbcBodyClusterRepository(private val connection: Connection) : BodyClusterRepository {
    override fun upsert(cluster: BodyClusterRecord) {
        connection.prepareStatement(
            """
            MERGE INTO body_clusters(id, representative_body_id, created_at)
            KEY(id) VALUES(?, ?, ?)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, cluster.id)
            statement.setString(2, cluster.representativeBodyId)
            statement.setLong(3, cluster.createdAt)
            statement.executeUpdate()
        }
    }

    override fun get(id: String): BodyClusterRecord? {
        return connection.selectOne("SELECT * FROM body_clusters WHERE id = ?", id) { it.toBodyClusterRecord() }
    }

    override fun findByRepresentativeBodyId(bodyId: String): BodyClusterRecord? {
        return connection.selectOne(
            "SELECT * FROM body_clusters WHERE representative_body_id = ? ORDER BY created_at",
            bodyId,
        ) { it.toBodyClusterRecord() }
    }
}

private class JdbcBookFileRepository(private val connection: Connection) : BookFileRepository {
    override fun upsert(file: BookFileRecord) {
        connection.prepareStatement(
            """
            MERGE INTO book_files(
                id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format,
                mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id,
                last_modified_millis, last_seen_at, reading_status, favorite, last_read_at, imported_at, created_at, updated_at
            )
            KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, file.id)
            statement.setStringOrNull(2, file.bookId)
            statement.setStringOrNull(3, file.bodyId)
            statement.setStringOrNull(4, file.bodyClusterId)
            statement.setStringOrNull(5, file.duplicateOfFileId)
            statement.setString(6, file.rawSha256)
            statement.setStringOrNull(7, file.format)
            statement.setStringOrNull(8, file.mimeType)
            statement.setLongOrNull(9, file.sizeBytes)
            statement.setStringOrNull(10, file.originalFilename)
            statement.setString(11, file.storageKind.name)
            statement.setStringOrNull(12, file.storageUri)
            statement.setStringOrNull(13, file.contentObjectId)
            statement.setLongOrNull(14, file.lastModifiedMillis)
            statement.setLongOrNull(15, file.lastSeenAt)
            statement.setString(16, file.readingStatus.name)
            statement.setBoolean(17, file.favorite)
            statement.setLongOrNull(18, file.lastReadAt)
            statement.setLong(19, file.importedAt)
            statement.setLong(20, file.createdAt)
            statement.setLong(21, file.updatedAt)
            statement.executeUpdate()
        }
    }

    override fun get(id: String): BookFileRecord? {
        return connection.selectOne("SELECT * FROM book_files WHERE id = ?", id) { it.toBookFileRecord() }
    }

    override fun getLibraryFile(id: String): LibraryFileRecord? {
        return connection.selectOne(
            """
            SELECT
                f.id AS file_id,
                f.book_id AS book_id,
                b.title AS book_title,
                b.authors AS book_authors,
                b.language AS book_language,
                b.published_date AS book_date,
                f.format AS file_format,
                f.size_bytes AS size_bytes,
                f.original_filename AS original_filename,
                f.storage_uri AS storage_uri,
                f.last_seen_at AS last_seen_at,
                f.reading_status AS reading_status,
                f.favorite AS favorite,
                f.last_read_at AS last_read_at,
                f.imported_at AS imported_at
            FROM book_files f
            LEFT JOIN books b ON b.id = f.book_id
            WHERE f.id = ?
            """.trimIndent(),
            id,
        ) { it.toLibraryFileRecord() }
    }

    override fun findByRawSha256(rawSha256: String): List<BookFileRecord> {
        return connection.selectMany("SELECT * FROM book_files WHERE raw_sha256 = ? ORDER BY created_at", rawSha256) {
            it.toBookFileRecord()
        }
    }

    override fun findByOriginalFilenameSizeAndModified(
        originalFilename: String,
        sizeBytes: Long,
        lastModifiedMillis: Long,
    ): List<BookFileRecord> {
        return connection.prepareStatement(
            """
            SELECT * FROM book_files
            WHERE original_filename = ?
              AND size_bytes = ?
              AND last_modified_millis = ?
            ORDER BY created_at
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, originalFilename)
            statement.setLong(2, sizeBytes)
            statement.setLong(3, lastModifiedMillis)
            statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
        }
    }

    override fun findByOriginalFilenameSizeAndRawSha256(
        originalFilename: String,
        sizeBytes: Long,
        rawSha256: String,
    ): List<BookFileRecord> {
        return connection.prepareStatement(
            """
            SELECT * FROM book_files
            WHERE original_filename = ?
              AND size_bytes = ?
              AND raw_sha256 = ?
            ORDER BY created_at
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, originalFilename)
            statement.setLong(2, sizeBytes)
            statement.setString(3, rawSha256)
            statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
        }
    }

    override fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord? {
        return connection.prepareStatement(
            """
            SELECT * FROM book_files
            WHERE duplicate_of_file_id IS NULL
              AND (
                    (? IS NOT NULL AND body_cluster_id = ?)
                 OR (? IS NOT NULL AND body_id = ?)
                 OR raw_sha256 = ?
              )
            ORDER BY
                CASE
                    WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
                      OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
                    THEN 0 ELSE 1
                END,
                CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
                last_read_at DESC NULLS LAST,
                updated_at DESC,
                id
            LIMIT 1
            """.trimIndent()
        ).use { statement ->
            statement.setStringOrNull(1, bodyClusterId)
            statement.setStringOrNull(2, bodyClusterId)
            statement.setStringOrNull(3, bodyId)
            statement.setStringOrNull(4, bodyId)
            statement.setString(5, rawSha256)
            statement.executeQuery().use { resultSet ->
                if (resultSet.next()) resultSet.toBookFileRecord() else null
            }
        }
    }

    override fun markDuplicateFiles(): Int {
        val now = System.currentTimeMillis()
        return connection.prepareStatement(
            """
            MERGE INTO book_files(id, duplicate_of_file_id, updated_at)
            KEY(id)
            SELECT id, primary_file_id, ?
            FROM (
                SELECT
                    id,
                    FIRST_VALUE(id) OVER (
                        PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
                        ORDER BY
                            CASE
                                WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
                                  OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
                                THEN 0 ELSE 1
                            END,
                            CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
                            last_read_at DESC NULLS LAST,
                            updated_at DESC,
                            id
                    ) AS primary_file_id,
                    ROW_NUMBER() OVER (
                        PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
                        ORDER BY
                            CASE
                                WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
                                  OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
                                THEN 0 ELSE 1
                            END,
                            CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
                            last_read_at DESC NULLS LAST,
                            updated_at DESC,
                            id
                    ) AS duplicate_rank
                FROM book_files
                WHERE duplicate_of_file_id IS NULL
            ) ranked
            WHERE duplicate_rank > 1
            """.trimIndent()
        ).use { statement ->
            statement.setLong(1, now)
            statement.executeUpdate()
        }
    }

    override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
        markDuplicateFiles()
        return connection.prepareStatement(
            """
            SELECT
                f.id AS file_id,
                f.book_id AS book_id,
                b.title AS book_title,
                b.authors AS book_authors,
                b.language AS book_language,
                b.published_date AS book_date,
                f.format AS file_format,
                f.size_bytes AS size_bytes,
                f.original_filename AS original_filename,
                f.storage_uri AS storage_uri,
                f.last_seen_at AS last_seen_at,
                f.reading_status AS reading_status,
                f.favorite AS favorite,
                f.last_read_at AS last_read_at,
                f.imported_at AS imported_at
            FROM book_files f
            LEFT JOIN books b ON b.id = f.book_id
            WHERE f.duplicate_of_file_id IS NULL
            """.trimIndent()
        ).use { statement ->
            statement.executeQuery().use { resultSet ->
                resultSet.mapRows { it.toLibraryFileRecord() }
                    .sortedWith(LibraryFileNaturalTitleComparator)
                    .drop(offset)
                    .take(limit)
            }
        }
    }

    override fun listRecentlyAddedLibraryFiles(sinceImportedAt: Long, limit: Int): List<LibraryFileRecord> {
        markDuplicateFiles()
        return connection.prepareStatement(
            """
            SELECT
                f.id AS file_id,
                f.book_id AS book_id,
                b.title AS book_title,
                b.authors AS book_authors,
                b.language AS book_language,
                b.published_date AS book_date,
                f.format AS file_format,
                f.size_bytes AS size_bytes,
                f.original_filename AS original_filename,
                f.storage_uri AS storage_uri,
                f.last_seen_at AS last_seen_at,
                f.reading_status AS reading_status,
                f.favorite AS favorite,
                f.last_read_at AS last_read_at,
                f.imported_at AS imported_at
            FROM book_files f
            LEFT JOIN books b ON b.id = f.book_id
            WHERE f.duplicate_of_file_id IS NULL
              AND f.reading_status = 'NEW'
              AND f.imported_at >= ?
            ORDER BY f.imported_at DESC, f.id
            LIMIT ?
            """.trimIndent()
        ).use { statement ->
            statement.setLong(1, sinceImportedAt)
            statement.setInt(2, limit)
            statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toLibraryFileRecord() } }
        }
    }

    override fun searchLibraryFiles(prefixes: List<String>, limit: Int): List<LibraryFileRecord> {
        val normalizedPrefixes = prefixes.mapNotNull { it.trim().lowercase().takeIf(String::isNotBlank) }.distinct()
        if (normalizedPrefixes.isEmpty()) return emptyList()

        markDuplicateFiles()
        val candidates = connection.prepareStatement(
            """
            SELECT
                f.id AS file_id,
                f.book_id AS book_id,
                b.title AS book_title,
                b.authors AS book_authors,
                b.language AS book_language,
                b.published_date AS book_date,
                b.description AS book_description,
                b.keywords AS book_keywords,
                f.format AS file_format,
                f.size_bytes AS size_bytes,
                f.original_filename AS original_filename,
                f.storage_uri AS storage_uri,
                f.last_seen_at AS last_seen_at,
                f.reading_status AS reading_status,
                f.favorite AS favorite,
                f.last_read_at AS last_read_at,
                f.imported_at AS imported_at
            FROM book_files f
            LEFT JOIN books b ON b.id = f.book_id
            WHERE f.duplicate_of_file_id IS NULL
            """.trimIndent()
        ).use { statement ->
            statement.executeQuery().use { resultSet ->
                resultSet.mapRows {
                    SearchCandidate(
                        file = it.toLibraryFileRecord(),
                        title = it.getString("book_title")?.takeIf(String::isNotBlank) ?: it.getString("original_filename"),
                        authors = it.getString("book_authors").fromDbList().joinToString(" "),
                        description = it.getString("book_description"),
                        keywords = it.getString("book_keywords"),
                    )
                }
            }
        }

        return candidates
            .mapNotNull { candidate ->
                candidate.searchRank(normalizedPrefixes)?.let { rank -> rank to candidate.file }
            }
            .sortedWith(
                compareByDescending<Pair<SearchRank, LibraryFileRecord>> { it.first.hitCount }
                    .thenBy { it.first.hitSpan }
                    .thenBy { it.first.firstHit }
                    .thenBy { it.second.title?.lowercase().orEmpty() }
                    .thenBy { it.second.fileId }
            )
            .take(limit)
            .map { it.second }
    }

    override fun list(limit: Int, offset: Int): List<BookFileRecord> {
        return connection.prepareStatement("SELECT * FROM book_files ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
            statement.setInt(1, limit)
            statement.setInt(2, offset)
            statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
        }
    }

    override fun listForBook(bookId: String): List<BookFileRecord> {
        return connection.selectMany("SELECT * FROM book_files WHERE book_id = ? ORDER BY created_at", bookId) {
            it.toBookFileRecord()
        }
    }

    override fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean {
        val now = System.currentTimeMillis()
        return connection.prepareStatement(
            """
            UPDATE book_files
            SET reading_status = ?,
                last_read_at = CASE
                    WHEN ? IN ('READING', 'READ') THEN ?
                    WHEN ? = 'NEW' THEN NULL
                    ELSE last_read_at
                END,
                updated_at = ?
            WHERE id = ?
               OR body_cluster_id = (SELECT body_cluster_id FROM book_files WHERE id = ? AND body_cluster_id IS NOT NULL)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, status.name)
            statement.setString(2, status.name)
            statement.setLong(3, now)
            statement.setString(4, status.name)
            statement.setLong(5, now)
            statement.setString(6, id)
            statement.setString(7, id)
            statement.executeUpdate() > 0
        }
    }

    override fun updateFavorite(id: String, favorite: Boolean): Boolean {
        val now = System.currentTimeMillis()
        return connection.prepareStatement(
            """
            UPDATE book_files
            SET favorite = ?, updated_at = ?
            WHERE id = ?
               OR body_cluster_id = (SELECT body_cluster_id FROM book_files WHERE id = ? AND body_cluster_id IS NOT NULL)
            """.trimIndent()
        ).use { statement ->
            statement.setBoolean(1, favorite)
            statement.setLong(2, now)
            statement.setString(3, id)
            statement.setString(4, id)
            statement.executeUpdate() > 0
        }
    }

    override fun touchLastReadAt(id: String, lastReadAt: Long): Boolean {
        return connection.prepareStatement(
            """
            UPDATE book_files
            SET last_read_at = ?, updated_at = ?
            WHERE id = ?
               OR body_cluster_id = (SELECT body_cluster_id FROM book_files WHERE id = ? AND body_cluster_id IS NOT NULL)
            """.trimIndent()
        ).use { statement ->
            statement.setLong(1, lastReadAt)
            statement.setLong(2, lastReadAt)
            statement.setString(3, id)
            statement.setString(4, id)
            statement.executeUpdate() > 0
        }
    }

    override fun delete(id: String): Boolean = connection.deleteById("book_files", id)
}

private class JdbcReadingStateRepository(private val connection: Connection) : ReadingStateRepository {
    override fun upsert(state: ReadingStateRecord) {
        connection.prepareStatement(
            """
            MERGE INTO reading_states(
                id, body_cluster_id, body_id, anchor_version, canonical_char_offset, canonical_token_offset,
                progress, exact_text, prefix_text, suffix_text, fingerprints_json, format_hints_json, updated_at
            )
            KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, state.id)
            statement.setString(2, state.bodyClusterId)
            statement.setStringOrNull(3, state.bodyId)
            statement.setAnchor(4, state.anchor)
            statement.setLong(13, state.updatedAt)
            statement.executeUpdate()
        }
    }

    override fun get(id: String): ReadingStateRecord? {
        return connection.selectOne("SELECT * FROM reading_states WHERE id = ?", id) { it.toReadingStateRecord() }
    }

    override fun getForBodyCluster(bodyClusterId: String): ReadingStateRecord? {
        return connection.selectOne("SELECT * FROM reading_states WHERE body_cluster_id = ? ORDER BY updated_at DESC", bodyClusterId) {
            it.toReadingStateRecord()
        }
    }

    override fun deleteForBodyCluster(bodyClusterId: String): Int {
        return connection.prepareStatement("DELETE FROM reading_states WHERE body_cluster_id = ?").use { statement ->
            statement.setString(1, bodyClusterId)
            statement.executeUpdate()
        }
    }

    override fun delete(id: String): Boolean = connection.deleteById("reading_states", id)
}

private class JdbcBookmarkRepository(private val connection: Connection) : BookmarkRepository {
    override fun upsert(bookmark: BookmarkRecord) {
        connection.prepareStatement(
            """
            MERGE INTO bookmarks(
                id, body_cluster_id, body_id, anchor_version, canonical_char_offset, canonical_token_offset,
                progress, exact_text, prefix_text, suffix_text, fingerprints_json, format_hints_json,
                title, selected_text_snapshot, color, created_at, updated_at
            )
            KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, bookmark.id)
            statement.setString(2, bookmark.bodyClusterId)
            statement.setStringOrNull(3, bookmark.bodyId)
            statement.setAnchor(4, bookmark.anchor)
            statement.setStringOrNull(13, bookmark.title)
            statement.setStringOrNull(14, bookmark.selectedTextSnapshot)
            statement.setStringOrNull(15, bookmark.color)
            statement.setLong(16, bookmark.createdAt)
            statement.setLong(17, bookmark.updatedAt)
            statement.executeUpdate()
        }
    }

    override fun get(id: String): BookmarkRecord? {
        return connection.selectOne("SELECT * FROM bookmarks WHERE id = ?", id) { it.toBookmarkRecord() }
    }

    override fun listForBodyCluster(bodyClusterId: String): List<BookmarkRecord> {
        return connection.selectMany("SELECT * FROM bookmarks WHERE body_cluster_id = ? ORDER BY progress, created_at", bodyClusterId) {
            it.toBookmarkRecord()
        }
    }

    override fun delete(id: String): Boolean = connection.deleteById("bookmarks", id)
}

private class JdbcNoteRepository(private val connection: Connection) : NoteRepository {
    override fun upsert(note: NoteRecord) {
        connection.prepareStatement(
            """
            MERGE INTO notes(
                id, bookmark_id, body_cluster_id, body_id, anchor_version, canonical_char_offset,
                canonical_token_offset, progress, exact_text, prefix_text, suffix_text, fingerprints_json,
                format_hints_json, text, created_at, updated_at
            )
            KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """.trimIndent()
        ).use { statement ->
            statement.setString(1, note.id)
            statement.setStringOrNull(2, note.bookmarkId)
            statement.setString(3, note.bodyClusterId)
            statement.setStringOrNull(4, note.bodyId)
            statement.setAnchor(5, note.anchor)
            statement.setString(14, note.text)
            statement.setLong(15, note.createdAt)
            statement.setLong(16, note.updatedAt)
            statement.executeUpdate()
        }
    }

    override fun get(id: String): NoteRecord? {
        return connection.selectOne("SELECT * FROM notes WHERE id = ?", id) { it.toNoteRecord() }
    }

    override fun listForBookmark(bookmarkId: String): List<NoteRecord> {
        return connection.selectMany("SELECT * FROM notes WHERE bookmark_id = ? ORDER BY created_at", bookmarkId) {
            it.toNoteRecord()
        }
    }

    override fun listForBodyCluster(bodyClusterId: String): List<NoteRecord> {
        return connection.selectMany("SELECT * FROM notes WHERE body_cluster_id = ? ORDER BY progress, created_at", bodyClusterId) {
            it.toNoteRecord()
        }
    }

    override fun delete(id: String): Boolean = connection.deleteById("notes", id)
}

private fun PreparedStatement.setAnchor(firstIndex: Int, anchor: ContentAnchor) {
    setInt(firstIndex, anchor.version)
    setLongOrNull(firstIndex + 1, anchor.canonicalCharOffset)
    setLongOrNull(firstIndex + 2, anchor.canonicalTokenOffset)
    setDoubleOrNull(firstIndex + 3, anchor.progress)
    setStringOrNull(firstIndex + 4, anchor.exact)
    setStringOrNull(firstIndex + 5, anchor.prefix)
    setStringOrNull(firstIndex + 6, anchor.suffix)
    setStringOrNull(firstIndex + 7, anchor.fingerprintsJson)
    setStringOrNull(firstIndex + 8, anchor.formatHintsJson)
}

private fun ResultSet.toAnchor(): ContentAnchor {
    return ContentAnchor(
        version = getInt("anchor_version"),
        canonicalCharOffset = getLongOrNull("canonical_char_offset"),
        canonicalTokenOffset = getLongOrNull("canonical_token_offset"),
        progress = getDoubleOrNull("progress"),
        exact = getString("exact_text"),
        prefix = getString("prefix_text"),
        suffix = getString("suffix_text"),
        fingerprintsJson = getString("fingerprints_json"),
        formatHintsJson = getString("format_hints_json"),
    )
}

private fun ResultSet.toBookRecord() = BookRecord(
    id = getString("id"),
    title = getString("title"),
    subtitle = getString("subtitle"),
    authors = getString("authors").fromDbList(),
    language = getString("language"),
    date = getString("published_date"),
    description = getString("description"),
    keywords = getString("keywords"),
    coverImage = getBytes("cover_image"),
    coverImageMimeType = getString("cover_image_mime_type"),
    createdAt = getLong("created_at"),
    updatedAt = getLong("updated_at"),
)

private data class SearchCandidate(
    val file: LibraryFileRecord,
    val title: String?,
    val authors: String?,
    val description: String?,
    val keywords: String?,
)

private data class SearchRank(
    val hitCount: Int,
    val hitSpan: Int,
    val firstHit: Int,
)

private fun SearchCandidate.searchRank(prefixes: List<String>): SearchRank? {
    val hitPositions = buildList {
        addAll(title.matchPositions(prefixes, 0))
        addAll(authors.matchPositions(prefixes, 100_000))
        addAll(keywords.matchPositions(prefixes, 200_000))
        addAll(description.matchPositions(prefixes, 300_000))
    }.sorted()
    if (hitPositions.isEmpty()) return null

    val first = hitPositions.first()
    val last = hitPositions.last()
    return SearchRank(
        hitCount = hitPositions.size,
        hitSpan = last - first,
        firstHit = first,
    )
}

private fun String?.matchPositions(prefixes: List<String>, fieldOffset: Int): List<Int> {
    if (isNullOrBlank()) return emptyList()
    return SearchWordRegex.findAll(lowercase())
        .mapIndexedNotNull { index, match ->
            if (prefixes.any { match.value.startsWith(it) }) fieldOffset + index else null
        }
        .toList()
}

private val SearchWordRegex = Regex("""[\p{L}\p{N}]+""")

private val LibraryFileNaturalTitleComparator = Comparator<LibraryFileRecord> { left, right ->
    val titleCompare = naturalCompare(left.sortTitle(), right.sortTitle())
    if (titleCompare != 0) titleCompare else left.fileId.compareTo(right.fileId)
}

private fun LibraryFileRecord.sortTitle(): String =
    title?.takeIf(String::isNotBlank)
        ?: originalFilename?.takeIf(String::isNotBlank)
        ?: fileId

private fun naturalCompare(left: String, right: String): Int {
    var leftIndex = 0
    var rightIndex = 0
    var exactFallback = 0
    while (leftIndex < left.length && rightIndex < right.length) {
        val leftChar = left[leftIndex]
        val rightChar = right[rightIndex]
        if (leftChar.isDigit() && rightChar.isDigit()) {
            val leftStart = leftIndex
            val rightStart = rightIndex
            while (leftIndex < left.length && left[leftIndex].isDigit()) leftIndex += 1
            while (rightIndex < right.length && right[rightIndex].isDigit()) rightIndex += 1

            val leftDigits = left.substring(leftStart, leftIndex).trimStart('0')
            val rightDigits = right.substring(rightStart, rightIndex).trimStart('0')
            val leftNumber = leftDigits.ifEmpty { "0" }
            val rightNumber = rightDigits.ifEmpty { "0" }
            val lengthCompare = leftNumber.length.compareTo(rightNumber.length)
            if (lengthCompare != 0) return lengthCompare
            val numberCompare = leftNumber.compareTo(rightNumber)
            if (numberCompare != 0) return numberCompare
            val rawLengthCompare = (leftIndex - leftStart).compareTo(rightIndex - rightStart)
            if (rawLengthCompare != 0) return rawLengthCompare
        } else {
            val foldedCompare = leftChar.lowercaseChar().compareTo(rightChar.lowercaseChar())
            if (foldedCompare != 0) return foldedCompare
            if (exactFallback == 0) exactFallback = leftChar.compareTo(rightChar)
            leftIndex += 1
            rightIndex += 1
        }
    }
    return left.length.compareTo(right.length).takeIf { it != 0 } ?: exactFallback
}

private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
    fileId = getString("file_id"),
    bookId = getString("book_id"),
    title = getString("book_title"),
    authors = getString("book_authors").fromDbList(),
    language = getString("book_language"),
    date = getString("book_date"),
    format = getString("file_format"),
    sizeBytes = getLongOrNull("size_bytes"),
    originalFilename = getString("original_filename"),
    storageUri = getString("storage_uri"),
    lastSeenAt = getLongOrNull("last_seen_at"),
    readingStatus = getReadingStatus("reading_status"),
    favorite = getBoolean("favorite"),
    lastReadAt = getLongOrNull("last_read_at"),
    importedAt = getLong("imported_at"),
)

private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
    id = getString("id"),
    exactTextHash = getString("exact_text_hash"),
    canonicalizationVersion = getInt("canonicalization_version"),
    nearSignature = getString("near_signature"),
    wordCount = getIntOrNull("word_count"),
    language = getString("language"),
    createdAt = getLong("created_at"),
)

private fun ResultSet.toBodyClusterRecord() = BodyClusterRecord(
    id = getString("id"),
    representativeBodyId = getString("representative_body_id"),
    createdAt = getLong("created_at"),
)

private fun ResultSet.toBookFileRecord() = BookFileRecord(
    id = getString("id"),
    bookId = getString("book_id"),
    bodyId = getString("body_id"),
    bodyClusterId = getString("body_cluster_id"),
    duplicateOfFileId = getString("duplicate_of_file_id"),
    rawSha256 = getString("raw_sha256"),
    format = getString("format"),
    mimeType = getString("mime_type"),
    sizeBytes = getLongOrNull("size_bytes"),
    originalFilename = getString("original_filename"),
    storageKind = BookFileStorageKind.valueOf(getString("storage_kind")),
    storageUri = getString("storage_uri"),
    contentObjectId = getString("content_object_id"),
    lastModifiedMillis = getLongOrNull("last_modified_millis"),
    lastSeenAt = getLongOrNull("last_seen_at"),
    readingStatus = getReadingStatus("reading_status"),
    favorite = getBoolean("favorite"),
    lastReadAt = getLongOrNull("last_read_at"),
    importedAt = getLong("imported_at"),
    createdAt = getLong("created_at"),
    updatedAt = getLong("updated_at"),
)

private fun ResultSet.toReadingStateRecord() = ReadingStateRecord(
    id = getString("id"),
    bodyClusterId = getString("body_cluster_id"),
    bodyId = getString("body_id"),
    anchor = toAnchor(),
    updatedAt = getLong("updated_at"),
)

private fun ResultSet.toBookmarkRecord() = BookmarkRecord(
    id = getString("id"),
    bodyClusterId = getString("body_cluster_id"),
    bodyId = getString("body_id"),
    anchor = toAnchor(),
    title = getString("title"),
    selectedTextSnapshot = getString("selected_text_snapshot"),
    color = getString("color"),
    createdAt = getLong("created_at"),
    updatedAt = getLong("updated_at"),
)

private fun ResultSet.toNoteRecord() = NoteRecord(
    id = getString("id"),
    bookmarkId = getString("bookmark_id"),
    bodyClusterId = getString("body_cluster_id"),
    bodyId = getString("body_id"),
    anchor = toAnchor(),
    text = getString("text"),
    createdAt = getLong("created_at"),
    updatedAt = getLong("updated_at"),
)

private fun <T> Connection.selectOne(sql: String, id: String, mapper: (ResultSet) -> T): T? {
    return prepareStatement(sql).use { statement ->
        statement.setString(1, id)
        statement.executeQuery().use { resultSet ->
            if (resultSet.next()) mapper(resultSet) else null
        }
    }
}

private fun <T> Connection.selectMany(sql: String, value: String, mapper: (ResultSet) -> T): List<T> {
    return prepareStatement(sql).use { statement ->
        statement.setString(1, value)
        statement.executeQuery().use { resultSet -> resultSet.mapRows(mapper) }
    }
}

private fun <T> ResultSet.mapRows(mapper: (ResultSet) -> T): List<T> {
    val result = mutableListOf<T>()
    while (next()) {
        result += mapper(this)
    }
    return result
}

private fun Connection.deleteById(tableName: String, id: String): Boolean {
    return prepareStatement("DELETE FROM $tableName WHERE id = ?").use { statement ->
        statement.setString(1, id)
        statement.executeUpdate() > 0
    }
}

private fun PreparedStatement.setStringOrNull(index: Int, value: String?) {
    if (value == null) setNull(index, java.sql.Types.VARCHAR) else setString(index, value)
}

private fun PreparedStatement.setIntOrNull(index: Int, value: Int?) {
    if (value == null) setNull(index, java.sql.Types.INTEGER) else setInt(index, value)
}

private fun PreparedStatement.setLongOrNull(index: Int, value: Long?) {
    if (value == null) setNull(index, java.sql.Types.BIGINT) else setLong(index, value)
}

private fun PreparedStatement.setDoubleOrNull(index: Int, value: Double?) {
    if (value == null) setNull(index, java.sql.Types.DOUBLE) else setDouble(index, value)
}

private fun PreparedStatement.setBytesOrNull(index: Int, value: ByteArray?) {
    if (value == null) setNull(index, java.sql.Types.BLOB) else setBytes(index, value)
}

private fun List<String>.toDbList(): String? =
    map { it.trim() }.filter { it.isNotBlank() }.joinToString("\n").takeIf { it.isNotBlank() }

private fun String?.fromDbList(): List<String> =
    orEmpty().lineSequence().map { it.trim() }.filter { it.isNotBlank() }.toList()

private fun ResultSet.getIntOrNull(column: String): Int? {
    val value = getInt(column)
    return if (wasNull()) null else value
}

private fun ResultSet.getLongOrNull(column: String): Long? {
    val value = getLong(column)
    return if (wasNull()) null else value
}

private fun ResultSet.getDoubleOrNull(column: String): Double? {
    val value = getDouble(column)
    return if (wasNull()) null else value
}

private fun ResultSet.getReadingStatus(column: String): BookReadingStatus =
    runCatching { BookReadingStatus.valueOf(getString(column)) }.getOrDefault(BookReadingStatus.NEW)
