package net.sergeych.toread

import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.ComponentCallbacks
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
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 net.sergeych.toread.storage.jdbc.LibraryScanSummary
import java.io.File
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 kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

private lateinit var appContext: Context
private var directoryChooser: AndroidLibraryDirectoryChooser? = null
private var pendingOpenBookUri: Uri? = null

interface AndroidLibraryDirectoryChooser {
    suspend fun chooseDirectory(): String?
    suspend fun ensureExternalFileAccess(): Boolean
}

fun initToreadPlatform(context: Context, chooser: AndroidLibraryDirectoryChooser? = null) {
    appContext = context.applicationContext
    directoryChooser = chooser
}

internal fun androidAppContext(): Context? =
    if (::appContext.isInitialized) appContext else null

actual fun loadDefaultBookBytes(): ByteArray? = null

actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
    runCatching {
        val bytes = binary.imageBytes()
        BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
    }.getOrNull()

actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
    runCatching {
        BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
    }.getOrNull()

actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
    runCatching {
        val imageDir = File(appContext.cacheDir, "clipboard-images").also { it.mkdirs() }
        val extension = when (mimeType.lowercase()) {
            "image/png" -> "png"
            "image/gif" -> "gif"
            "image/webp" -> "webp"
            else -> "jpg"
        }
        val imageFile = File(imageDir, "book-image.$extension")
        imageFile.writeBytes(bytes)

        val uri = FileProvider.getUriForFile(
            appContext,
            "${appContext.packageName}.imageviewer.fileprovider",
            imageFile,
        )
        val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
        val clip = ClipData.newUri(appContext.contentResolver, label, uri).apply {
            description.extras = android.os.PersistableBundle().apply {
                putString("mime_type", mimeType)
            }
        }
        clipboard.setPrimaryClip(clip)
        true
    }.getOrDefault(false)
}

actual fun defaultLibraryScanPath(): String? =
    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
        ?: appContext.getExternalFilesDir(null)?.absolutePath
        ?: appContext.filesDir.absolutePath

fun rememberPlatformOpenBookIntent(intent: Intent?) {
    val uri = intent
        ?.takeIf { it.action == Intent.ACTION_VIEW }
        ?.data
    if (uri != null && intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) {
        runCatching {
            appContext.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }
    }
    pendingOpenBookUri = intent
        ?.takeIf { it.action == Intent.ACTION_VIEW }
        ?.data
}

actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = withContext(Dispatchers.IO) {
    val uri = pendingOpenBookUri ?: return@withContext null
    pendingOpenBookUri = null
    val bytes = readStorageUriBytes(uri.toString()) ?: return@withContext null
    val displayName = displayNameFor(uri)
    val sizeBytes = sizeBytesFor(uri) ?: bytes.size.toLong()
    val importResult = openLibraryDatabase().useLibrary { db ->
        LibraryScanner(db, ::appendLibraryLog).importExternalFileAndGetResult(
            bytes = bytes,
            displayName = displayName,
            storageUri = uri.toString(),
            sizeBytes = sizeBytes,
            lastModifiedMillis = null,
        )
    }
    appendLibraryLog("platform open imported=${importResult.imported} fileId=${importResult.fileId} uri=$uri")
    PlatformOpenBookRequest(
        id = importResult.fileId,
        displayName = displayName,
        bytes = bytes,
    )
}

actual suspend fun chooseLibraryScanDirectory(): String? = directoryChooser?.chooseDirectory()

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 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 {
            if (path.isContentUri()) {
                countContentTree(Uri.parse(path))
            } else {
                LibraryScanner.countSupportedBookFiles(File(path))
            }
        }.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) {
        if (path.isContentUri()) {
            scanLibraryContentTree(Uri.parse(path), ::emitProgress)
        } else {
            if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
                error(strings.allFilesAccessRequired(path))
            }
            openLibraryDatabase().useLibrary { db ->
                val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
                    emitProgress(it.toLibraryScanProgress())
                }
                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)
}

private fun countContentTree(rootUri: Uri): Int {
    var count = 0
    walkContentTree(
        rootUri = rootUri,
        onVisited = {},
    ) {
        count += 1
    }
    return count
}

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(::readStorageUriBytes)
        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(::readStorageUriBytes)
            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 = withContext(Dispatchers.IO) {
    runCatching {
        val shareFile = openLibraryDatabase().useLibrary { db ->
            val file = db.files.getLibraryFile(fileId) ?: return@useLibrary null
            val bytes = readStorageUriBytes(file.storageUri ?: return@useLibrary null) ?: return@useLibrary null
            val shareDir = File(appContext.cacheDir, "shared-books").also { it.mkdirs() }
            File(shareDir, file.shareFileName()).also { it.writeBytes(bytes) }
        } ?: return@withContext false

        val uri = FileProvider.getUriForFile(
            appContext,
            "${appContext.packageName}.imageviewer.fileprovider",
            shareFile,
        )
        val mimeType = shareFile.bookMimeType()
        val intent = Intent(Intent.ACTION_SEND).apply {
            type = mimeType
            putExtra(Intent.EXTRA_STREAM, uri)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }
        val chooser = Intent.createChooser(intent, strings.shareBook).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }
        appContext.startActivity(chooser)
        true
    }.getOrDefault(false)
}

actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false

actual fun openPlatformUrl(url: String): Boolean =
    runCatching {
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        appContext.startActivity(intent)
        true
    }.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('/')
        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? =
    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath

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

private fun isUnderDownloadsPath(path: String): Boolean {
    if (path.isContentUri()) return false
    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 {
    if (!::appContext.isInitialized) return false
    val uiMode = appContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
    return uiMode == Configuration.UI_MODE_NIGHT_YES
}

actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
    if (!::appContext.isInitialized) return {}
    val callback = object : ComponentCallbacks {
        override fun onConfigurationChanged(newConfig: Configuration) {
            val uiMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK
            onChange(uiMode == Configuration.UI_MODE_NIGHT_YES)
        }

        override fun onLowMemory() = Unit
    }
    appContext.registerComponentCallbacks(callback)
    return { appContext.unregisterComponentCallbacks(callback) }
}

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

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

private data class AndroidLibraryDocument(
    val uri: Uri,
    val name: String,
    val sizeBytes: Long?,
    val lastModifiedMillis: Long?,
)

private fun scanLibraryContentTree(
    rootUri: Uri,
    onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport =
    openLibraryDatabase().useLibrary { db ->
        val scanner = LibraryScanner(db, ::appendLibraryLog)
        var scanned = 0
        var imported = 0
        var skipped = 0
        var failed = 0
        var visited = 0

        walkContentTree(
            rootUri = rootUri,
            onVisited = { name ->
                visited += 1
                if (visited % 50 == 0) {
                    onProgress(LibraryScanProgress(scanned, imported, skipped, failed, name))
                }
            },
        ) { document ->
            scanned += 1
            appendLibraryLog("scan document uri=${document.uri} name=${document.name} size=${document.sizeBytes ?: 0}")
            onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
            val result = runCatching {
                val bytes = appContext.contentResolver.openInputStream(document.uri)?.use { it.readBytes() }
                    ?: error(strings.couldNotRead(document.name))
                scanner.importExternalFile(
                    bytes = bytes,
                    displayName = document.name,
                    storageUri = document.uri.toString(),
                    sizeBytes = document.sizeBytes,
                    lastModifiedMillis = document.lastModifiedMillis,
                )
            }
            if (result.isSuccess) {
                if (result.getOrThrow()) {
                    imported += 1
                    appendLibraryLog("scan imported uri=${document.uri}")
                } else {
                    skipped += 1
                    appendLibraryLog("scan skipped duplicate uri=${document.uri}")
                }
            } else {
                failed += 1
                appendLibraryLog("scan failed uri=${document.uri} error=${result.exceptionOrNull()?.message}")
            }
            onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
        }

        LibraryScanReport(
            scannedFiles = scanned,
            importedFiles = imported,
            skippedFiles = skipped,
            failedFiles = failed,
        )
    }

private fun walkContentTree(
    rootUri: Uri,
    onVisited: (String) -> Unit,
    onBookFile: (AndroidLibraryDocument) -> Unit,
) {
    val rootDocumentId = DocumentsContract.getTreeDocumentId(rootUri)
    val rootDocumentUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, rootDocumentId)
    walkContentDocument(rootUri, rootDocumentUri, onVisited, onBookFile)
}

private fun walkContentDocument(
    treeUri: Uri,
    documentUri: Uri,
    onVisited: (String) -> Unit,
    onBookFile: (AndroidLibraryDocument) -> Unit,
) {
    val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
        treeUri,
        DocumentsContract.getDocumentId(documentUri),
    )
    val columns = arrayOf(
        DocumentsContract.Document.COLUMN_DOCUMENT_ID,
        DocumentsContract.Document.COLUMN_DISPLAY_NAME,
        DocumentsContract.Document.COLUMN_MIME_TYPE,
        DocumentsContract.Document.COLUMN_SIZE,
        DocumentsContract.Document.COLUMN_LAST_MODIFIED,
    )
    appContext.contentResolver.query(childrenUri, columns, null, null, null)?.use { cursor ->
        val idIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
        val nameIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
        val mimeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
        val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
        val modifiedIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)

        while (cursor.moveToNext()) {
            val documentId = cursor.getString(idIndex)
            val name = cursor.getString(nameIndex).orEmpty()
            val mimeType = cursor.getString(mimeIndex)
            val childUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
            onVisited(name)
            if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
                walkContentDocument(treeUri, childUri, onVisited, onBookFile)
            } else if (name.isSupportedBookFile()) {
                onBookFile(
                    AndroidLibraryDocument(
                        uri = childUri,
                        name = name,
                        sizeBytes = cursor.getNullableLong(sizeIndex),
                        lastModifiedMillis = cursor.getNullableLong(modifiedIndex),
                    )
                )
            }
        }
    }
}

private fun readStorageUriBytes(storageUri: String): ByteArray? =
    if (storageUri.isContentUri()) {
        appContext.contentResolver.openInputStream(Uri.parse(storageUri))?.use { it.readBytes() }
    } else {
        File(storageUri).takeIf { it.isFile }?.readBytes()
    }

private fun String.isContentUri(): Boolean =
    runCatching { Uri.parse(this).scheme == ContentResolver.SCHEME_CONTENT }.getOrDefault(false)

private fun String.requiresExternalFileAccess(): Boolean {
    val externalRoot = Environment.getExternalStorageDirectory()?.absolutePath ?: return false
    val appExternalRoot = appContext.getExternalFilesDir(null)?.absolutePath
    return startsWith(externalRoot) && (appExternalRoot == null || !startsWith(appExternalRoot))
}

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

private fun LibraryFileRecord.shareFileName(): String {
    val raw = originalFilename?.takeIf { it.isNotBlank() }
        ?: title?.takeIf { it.isNotBlank() }?.let { title ->
            val extension = when {
                format.equals("fb2.zip", ignoreCase = true) || storageUri?.endsWith(".fb2.zip", ignoreCase = true) == true -> ".fb2.zip"
                else -> ".fb2"
            }
            "$title$extension"
        }
        ?: "book.fb2"
    return raw.replace(Regex("""[\\/:*?"<>|]+"""), "_")
}

private fun File.bookMimeType(): String =
    if (name.endsWith(".zip", ignoreCase = true)) {
        "application/zip"
    } else {
        "application/x-fictionbook+xml"
    }

private fun displayNameFor(uri: Uri): String =
    appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
        if (cursor.moveToFirst()) cursor.getString(0) else null
    } ?: uri.lastPathSegment ?: uri.toString()

private fun sizeBytesFor(uri: Uri): Long? =
    appContext.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
        if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else null
    }

private fun LibraryScanSummary.toLibraryScanProgress(): LibraryScanProgress =
    LibraryScanProgress(
        scannedFiles = scannedFiles,
        importedFiles = importedFiles,
        skippedFiles = skippedFiles,
        failedFiles = failedFiles,
        currentFile = currentFile,
    )

private fun android.database.Cursor.getNullableLong(index: Int): Long? =
    if (isNull(index)) null else getLong(index)

private fun openLibraryDatabase(): H2LibraryDatabase {
    val libraryDir = File(appContext.filesDir, "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 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(appContext.filesDir, "logs/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 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"
