package net.sergeych.toread

import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image
import java.awt.Desktop
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.io.File
import java.io.ByteArrayInputStream
import java.net.URI
import javax.imageio.ImageIO
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser
import kotlin.concurrent.thread
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

private var pendingOpenBookFile: File? = null

fun rememberPlatformOpenBookArguments(args: Array<String>) {
    pendingOpenBookFile = args.asSequence()
        .map(::File)
        .firstOrNull { it.isFile && it.name.isSupportedBookFile() }
}

actual fun loadDefaultBookBytes(): ByteArray? {
    var current = File(System.getProperty("user.dir")).absoluteFile
    while (true) {
        val candidate = File(current, "test_books/$DefaultBookFileName")
        if (candidate.isFile) return candidate.readBytes()
        current = current.parentFile ?: break
    }
    return null
}

actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
    runCatching { Image.makeFromEncoded(binary.imageBytes()).toComposeImageBitmap() }.getOrNull()

actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
    runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()

actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
    runCatching {
        val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext false
        val transferable = object : Transferable {
            override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.imageFlavor)
            override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor
            override fun getTransferData(flavor: DataFlavor): Any {
                if (!isDataFlavorSupported(flavor)) throw UnsupportedFlavorException(flavor)
                return image
            }
        }
        Toolkit.getDefaultToolkit().systemClipboard.setContents(transferable, null)
        true
    }.getOrDefault(false)
}

actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }

actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = withContext(Dispatchers.IO) {
    val file = pendingOpenBookFile ?: return@withContext null
    pendingOpenBookFile = null
    val bytes = file.readBytes()
    val importResult = openLibraryDatabase().useLibrary { db ->
        LibraryScanner(db, ::appendLibraryLog).importExternalFileAndGetResult(
            bytes = bytes,
            displayName = file.name,
            storageUri = file.absolutePath,
            sizeBytes = file.length(),
            lastModifiedMillis = file.lastModified(),
        )
    }
    appendLibraryLog("platform open imported=${importResult.imported} fileId=${importResult.fileId} path=${file.absolutePath}")
    PlatformOpenBookRequest(
        id = importResult.fileId,
        displayName = file.name,
        bytes = bytes,
    )
}

actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
    val chooser = JFileChooser(defaultLibraryScanPath())
    chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
    chooser.dialogTitle = strings.chooseLibraryFolder
    if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
        chooser.selectedFile.absolutePath
    } else {
        null
    }
}

actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
    loadLibraryItemsPage(Int.MAX_VALUE, 0)
}

actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
    appendLibraryLog("load library items page limit=$limit offset=$offset")
    openLibraryDatabase().useLibrary { db ->
        db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
    }
}

actual suspend fun loadRecentlyAddedLibraryItems(sinceImportedAt: Long, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
    appendLibraryLog("load recently added library items since=$sinceImportedAt limit=$limit")
    openLibraryDatabase().useLibrary { db ->
        db.files.listRecentlyAddedLibraryFiles(sinceImportedAt, limit).map { it.toLibraryItem() }
    }
}

actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
    val prefixes = query.toSearchPrefixes()
    if (prefixes.isEmpty()) return@withContext emptyList()
    appendLibraryLog("search library items prefixes=${prefixes.joinToString()}")
    openLibraryDatabase().useLibrary { db ->
        db.files.searchLibraryFiles(prefixes, limit).map { it.toLibraryItem() }
    }
}

actual fun currentLibraryTimeMillis(): Long = System.currentTimeMillis()

actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.files.getLibraryFile(fileId)?.toLibraryItem()
    }
}

actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        val bookId = db.files.get(fileId)?.bookId ?: return@useLibrary null
        db.books.getCover(bookId)?.let { LibraryCover(image = it.image, mimeType = it.mimeType) }
    }
}

actual suspend fun scanLibrarySubtree(
    path: String,
    onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = coroutineScope {
    withContext(Dispatchers.IO) {
        appendLibraryLog("scan requested path=$path")
    }
    val root = File(path)
    val totalFiles = AtomicInteger(-1)
    val latestProgress = AtomicReference<LibraryScanProgress?>(null)

    fun emitProgress(progress: LibraryScanProgress) {
        val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
        latestProgress.set(enriched)
        onProgress(enriched)
    }

    val totalJob = async(Dispatchers.IO) {
        runCatching { LibraryScanner.countSupportedBookFiles(root) }.getOrNull()
    }
    val totalPublisher = launch(Dispatchers.IO) {
        val total = totalJob.await() ?: return@launch
        totalFiles.set(total)
        emitProgress(latestProgress.get()?.copy(totalFiles = total) ?: LibraryScanProgress(0, 0, 0, 0, totalFiles = total))
    }

    val report = withContext(Dispatchers.IO) {
        openLibraryDatabase().useLibrary { db ->
            val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(root) {
                emitProgress(
                    LibraryScanProgress(
                        scannedFiles = it.scannedFiles,
                        importedFiles = it.importedFiles,
                        skippedFiles = it.skippedFiles,
                        failedFiles = it.failedFiles,
                        currentFile = it.currentFile,
                    )
                )
            }
            LibraryScanReport(
                scannedFiles = summary.scannedFiles,
                importedFiles = summary.importedFiles,
                skippedFiles = summary.skippedFiles,
                failedFiles = summary.failedFiles,
                totalFiles = totalFiles.get().takeIf { it >= 0 },
            )
        }
    }
    totalPublisher.join()
    report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
}

actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
    appendLibraryLog("open book fileId=$fileId")
    openLibraryDatabase().useLibrary { db ->
        val file = db.files.get(fileId) ?: return@useLibrary null
        val bytes = file.storageUri?.let { File(it) }?.takeIf { it.isFile }?.readBytes()
        appendLibraryLog("open book fileId=$fileId bytes=${bytes?.size ?: 0} uri=${file.storageUri}")
        bytes
    }
}

actual suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: Fb2Book): LibraryItem? = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.refreshBookCardFromParsedBook(fileId, book).item
    }
}

actual suspend fun rescanAllLibraryBooks(): LibraryRescanReport = withContext(Dispatchers.IO) {
    appendLibraryLog("rescan all library requested")
    openLibraryDatabase().useLibrary { db ->
        var scanned = 0
        var updated = 0
        var failed = 0
        db.files.list(Int.MAX_VALUE, 0).forEach { file ->
            val bytes = file.storageUri?.let { File(it) }?.takeIf { it.isFile }?.readBytes()
            if (bytes == null) {
                failed += 1
                appendLibraryLog("rescan missing fileId=${file.id} uri=${file.storageUri}")
                return@forEach
            }
            scanned += 1
            val parsed = runCatching {
                Fb2Format.parse(bytes, file.originalFilename ?: file.storageUri ?: file.id)
            }
            parsed
                .onSuccess {
                    if (db.refreshBookCardFromParsedBook(file.id, it).updated) {
                        updated += 1
                    }
                }
                .onFailure {
                    failed += 1
                    appendLibraryLog("rescan failed fileId=${file.id} error=${it.message}")
                }
        }
        appendLibraryLog("rescan all library finished scanned=$scanned updated=$updated failed=$failed")
        LibraryRescanReport(scannedFiles = scanned, updatedFiles = updated, failedFiles = failed)
    }
}

actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
    appendLibraryLog("delete fileId=$fileId")
    openLibraryDatabase().useLibrary { db ->
        val deleted = db.files.delete(fileId)
        appendLibraryLog("delete fileId=$fileId deleted=$deleted")
        deleted
    }
}

actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        val file = db.files.get(fileId) ?: return@useLibrary null
        val clusterId = file.bodyClusterId ?: return@useLibrary null
        db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
    }
}

actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        val file = db.files.get(fileId) ?: return@useLibrary
        val clusterId = file.bodyClusterId ?: return@useLibrary
        val now = System.currentTimeMillis()
        db.readingStates.upsert(
            ReadingStateRecord(
                id = "state-$clusterId",
                bodyClusterId = clusterId,
                bodyId = file.bodyId,
                anchor = ContentAnchor(
                    progress = position.itemIndex.toDouble(),
                    formatHintsJson = position.toFormatHintsJson(),
                ),
                updatedAt = now,
            ),
        )
        db.files.touchLastReadAt(fileId, now)
    }
}

actual suspend fun clearLibraryReadingPosition(fileId: String) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        val file = db.files.get(fileId) ?: return@useLibrary
        val clusterId = file.bodyClusterId ?: return@useLibrary
        db.readingStates.deleteForBodyCluster(clusterId)
    }
    Unit
}

actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.transaction {
            if (status == BookReadingStatus.NEW) {
                val file = files.get(fileId) ?: return@transaction false
                file.bodyClusterId?.let { readingStates.deleteForBodyCluster(it) }
            }
            files.updateReadingStatus(fileId, status)
        }
    }
}

actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.files.updateFavorite(fileId, favorite)
    }
}

actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false

actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = withContext(Dispatchers.IO) {
    runCatching {
        val directory = File(folder).takeIf { it.isDirectory } ?: return@withContext false
        val file = File(directory, fileName).takeIf { it.isFile } ?: return@withContext false
        selectOrOpenBookFile(directory, file)
    }.getOrDefault(false)
}

actual fun openPlatformUrl(url: String): Boolean =
    runCatching {
        if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
            return@runCatching false
        }
        Desktop.getDesktop().browse(URI(url))
        true
    }.getOrDefault(false)

private fun selectOrOpenBookFile(directory: File, file: File): Boolean {
    if (isLinuxDesktop() && trySelectLinuxFile(file)) return true
    if (!Desktop.isDesktopSupported()) return false

    val desktop = Desktop.getDesktop()
    return when {
        desktop.isSupported(Desktop.Action.BROWSE_FILE_DIR) -> {
            desktop.browseFileDirectory(file)
            true
        }
        desktop.isSupported(Desktop.Action.OPEN) -> {
            desktop.open(directory)
            true
        }
        else -> false
    }
}

private fun isLinuxDesktop(): Boolean =
    System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT).contains("linux")

private fun trySelectLinuxFile(file: File): Boolean =
    runCatching {
        val process = ProcessBuilder(
            "dbus-send",
            "--session",
            "--dest=org.freedesktop.FileManager1",
            "--type=method_call",
            "/org/freedesktop/FileManager1",
            "org.freedesktop.FileManager1.ShowItems",
            "array:string:${file.toURI()}",
            "string:",
        ).start()
        if (process.waitFor(2, TimeUnit.SECONDS)) {
            process.exitValue() == 0
        } else {
            process.destroyForcibly()
            false
        }
    }.getOrDefault(false)

actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
        val sourceFileName = file.originalFilename ?: file.storageUri?.substringAfterLast(File.separatorChar)
        val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras(
            sourceFileName = sourceFileName,
            sourceFilePath = file.storageUri,
        )
        val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
        BookInfoExtras(
            sourceFileName = sourceFileName,
            sourceFilePath = file.storageUri,
            bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
                BookmarkInfo(
                    title = it.title,
                    selectedText = it.selectedTextSnapshot,
                    progress = it.anchor.progress,
                    updatedAt = it.updatedAt,
                )
            },
            notes = db.notes.listForBodyCluster(clusterId).map {
                NoteInfo(
                    text = it.text,
                    progress = it.anchor.progress,
                    updatedAt = it.updatedAt,
                )
            },
            lastReadingPosition = readingPosition,
        )
    }
}

actual suspend fun loadActiveReadingFileId(): String? = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.getAppFlag(ActiveReadingFileIdFlag)?.takeIf { it.isNotBlank() }
    }
}

actual suspend fun saveActiveReadingFileId(fileId: String?) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(ActiveReadingFileIdFlag, fileId?.takeIf { it.isNotBlank() })
    }
}

actual suspend fun loadThemeMode(): ThemeMode = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.getAppFlag(ThemeModeFlag)?.let { value ->
            runCatching { ThemeMode.valueOf(value) }.getOrNull()
        } ?: ThemeMode.SYSTEM
    }
}

actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(ThemeModeFlag, mode.name)
    }
}

actual suspend fun loadReaderFontSettings(): ReaderFontSettings = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        readerFontSettingsFromStorageValue(db.getAppFlag(ReaderFontSettingsFlag))
    }
}

actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(ReaderFontSettingsFlag, settings.toStorageValue())
    }
}

actual suspend fun loadReaderFullScreen(): Boolean = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.getAppFlag(ReaderFullScreenFlag)?.toBooleanStrictOrNull() ?: false
    }
}

actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(ReaderFullScreenFlag, fullScreen.toString())
    }
}

actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
    }
}

actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(ScanDownloadsAutomaticallyFlag, enabled.toString())
    }
}

internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        val saved = db.getAppFlag(LibraryFilterFlag)
        when {
            saved == null -> {
                appendLibraryLog("warning library filter was not saved; using ${LibraryFilter.MyLibrary.name}")
                LibraryFilter.MyLibrary
            }
            else -> runCatching { LibraryFilter.valueOf(saved) }
                .getOrElse {
                    appendLibraryLog("warning invalid saved library filter '$saved'; using ${LibraryFilter.MyLibrary.name}")
                    LibraryFilter.MyLibrary
                }
        }
    }
}

internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(LibraryFilterFlag, filter.name)
    }
}

actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
    }
}

actual suspend fun saveAppLocaleTag(localeTag: String?) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(AppLocaleTagFlag, localeTag?.takeIf { it.isNotBlank() })
    }
}

actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
            ?: db.files.list(Int.MAX_VALUE, 0).any { it.storageUri?.let(::isUnderDownloadsPath) == true }
    }
}

actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.setAppFlag(DownloadsWasScannedFlag, scanned.toString())
    }
}

actual fun downloadsScanPath(): String? =
    File(System.getProperty("user.home"), "Downloads").takeIf { it.isDirectory }?.absolutePath

actual fun isDownloadsScanPath(path: String): Boolean {
    val downloads = downloadsScanPath() ?: return false
    return runCatching { File(path).canonicalFile == File(downloads).canonicalFile }.getOrDefault(false)
}

private fun isUnderDownloadsPath(path: String): Boolean {
    val downloads = downloadsScanPath() ?: return false
    return runCatching {
        val filePath = File(path).canonicalPath
        val downloadsPath = File(downloads).canonicalPath.trimEnd(File.separatorChar)
        filePath == downloadsPath || filePath.startsWith(downloadsPath + File.separator)
    }.getOrDefault(false)
}

actual fun isPlatformDarkTheme(): Boolean {
    val osName = System.getProperty("os.name").lowercase()
    if (!osName.contains("linux")) return false

    val gtkTheme = System.getenv("GTK_THEME").orEmpty()
    if (gtkTheme.contains("dark", ignoreCase = true)) return true

    val colorScheme = runCommand("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
    if (colorScheme?.contains("prefer-dark", ignoreCase = true) == true) return true

    val themeName = runCommand("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme")
    if (themeName?.contains("dark", ignoreCase = true) == true) return true

    val kdeGlobals = File(System.getProperty("user.home"), ".config/kdeglobals")
    return runCatching {
        kdeGlobals.takeIf { it.isFile }
            ?.readText()
            ?.lineSequence()
            ?.any { line ->
                line.startsWith("ColorScheme=", ignoreCase = true) &&
                    line.contains("dark", ignoreCase = true)
            } == true
    }.getOrDefault(false)
}

actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
    val osName = System.getProperty("os.name").lowercase()
    if (!osName.contains("linux")) return {}

    val running = AtomicBoolean(true)
    val worker = thread(name = "toread-theme-watch", isDaemon = true) {
        var last = isPlatformDarkTheme()
        while (running.get()) {
            try {
                Thread.sleep(1500)
                val current = isPlatformDarkTheme()
                if (current != last) {
                    last = current
                    onChange(current)
                }
            } catch (_: InterruptedException) {
                return@thread
            }
        }
    }
    return {
        running.set(false)
        worker.interrupt()
    }
}

actual fun libraryLogPath(): String? = libraryLogFile().absolutePath

actual fun formatLibraryLastReadTime(millis: Long): String =
    SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis))

private fun openLibraryDatabase(): H2LibraryDatabase {
    val libraryDir = File(System.getProperty("user.home"), ".toread/library")
    val dbDir = File(libraryDir, "db")
    dbDir.mkdirs()
    return H2LibraryDatabase.openFile(File(dbDir, "library").absolutePath)
}

private fun <T> H2LibraryDatabase.useLibrary(block: (H2LibraryDatabase) -> T): T =
    try {
        block(this)
    } finally {
        close()
    }

private fun findProjectRoot(): File? {
    var current = File(System.getProperty("user.dir")).absoluteFile
    while (true) {
        if (File(current, "test_books").isDirectory) return current
        current = current.parentFile ?: break
    }
    return null
}

private fun appendLibraryLog(message: String) {
    val file = libraryLogFile()
    file.parentFile?.mkdirs()
    file.appendText("${System.currentTimeMillis()} $message\n")
}

private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
    LibraryItem(
        fileId = fileId,
        bookId = bookId,
        title = title ?: originalFilename ?: fileId,
        authors = authors,
        language = language,
        date = date,
        format = format,
        sizeBytes = sizeBytes,
        storageUri = storageUri,
        lastSeenAt = lastSeenAt,
        readingStatus = readingStatus,
        favorite = favorite,
        lastReadAt = lastReadAt,
        importedAt = importedAt,
    )

private data class ParsedBookCover(
    val bytes: ByteArray,
    val mimeType: String?,
)

private data class BookCardRefresh(
    val updated: Boolean,
    val item: LibraryItem?,
)

private fun H2LibraryDatabase.refreshBookCardFromParsedBook(fileId: String, book: Fb2Book): BookCardRefresh {
    val file = files.get(fileId) ?: return BookCardRefresh(updated = false, item = null)
    val bookId = file.bookId ?: return BookCardRefresh(updated = false, item = null)
    val stored = books.get(bookId) ?: return BookCardRefresh(updated = false, item = null)
    val cover = book.libraryCardCover()
    val next = stored.copy(
        title = book.title.ifBlank { file.originalFilename?.substringBeforeLast('.') ?: stored.title.orEmpty() },
        authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
        language = book.language,
        date = book.date,
        description = book.annotation,
        keywords = book.keywords,
        coverImage = cover?.bytes,
        coverImageMimeType = cover?.mimeType,
        updatedAt = System.currentTimeMillis(),
    )
    val updated = !stored.hasSameCardMetadata(next)
    if (updated) {
        appendLibraryLog("refresh book card fileId=$fileId bookId=$bookId title=${next.title}")
        books.upsert(next)
    }
    return BookCardRefresh(updated = updated, item = files.getLibraryFile(fileId)?.toLibraryItem())
}

private fun Fb2Book.libraryCardCover(): ParsedBookCover? {
    val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
    val binary = image?.let(::binaryFor) ?: return null
    return runCatching {
        ParsedBookCover(bytes = binary.imageBytes(), mimeType = binary.contentType)
    }.getOrNull()
}

private fun BookRecord.hasSameCardMetadata(other: BookRecord): Boolean =
    title == other.title &&
        authors == other.authors &&
        language == other.language &&
        date == other.date &&
        description == other.description &&
        keywords == other.keywords &&
        coverImage.contentEquals(other.coverImage) &&
        coverImageMimeType == other.coverImageMimeType

private fun libraryLogFile(): File =
    File(System.getProperty("user.home"), ".toread/toread.log")

private fun ReadingPosition.toFormatHintsJson(): String =
    buildString {
        append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""")
        readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") }
        if (backStack.isNotEmpty()) {
            append(""","backStack":[""")
            append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() })
            append("]")
        }
        append("}")
    }

private fun String.toReadingPosition(): ReadingPosition? {
    val position = toReadingPositionObject() ?: return null
    val backStackJson = Regex(""""backStack"\s*:\s*\[(.*)]""").find(this)?.groupValues?.getOrNull(1)
    val backStack = backStackJson
        ?.let { ReadingPositionObjectRegex.findAll(it).mapNotNull { match -> match.value.toReadingPositionObject() }.toList() }
        .orEmpty()
        .take(MaxStoredReadingBackStack)
    return position.copy(backStack = backStack)
}

private fun String.toReadingPositionObject(): ReadingPosition? {
    val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
    val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
    val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
    return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null
}

private val ReadingPositionObjectRegex = Regex("""[{][^{}]*[}]""")
private const val MaxStoredReadingBackStack = 10

private fun String.toSearchPrefixes(): List<String> =
    SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()

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

private fun runCommand(vararg command: String): String? =
    runCatching {
        val process = ProcessBuilder(*command).redirectErrorStream(true).start()
        if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
            process.destroy()
            null
        } else {
            process.inputStream.bufferedReader().readText().trim()
        }
    }.getOrNull()

private fun String.isSupportedBookFile(): Boolean =
    endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)

private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ReaderFullScreenFlag = "reader_full_screen"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val LibraryFilterFlag = "library_filter"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"
