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.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.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 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

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? = null

actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
    val chooser = JFileChooser(defaultLibraryScanPath())
    chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
    chooser.dialogTitle = "Choose library folder"
    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 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 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 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 markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        db.files.updateReadingStatus(fileId, status)
    }
}

actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
    openLibraryDatabase().useLibrary { db ->
        val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
        val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras()
        val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
        BookInfoExtras(
            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 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())
    }
}

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,
        lastReadAt = lastReadAt,
        importedAt = importedAt,
    )

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

private fun ReadingPosition.toFormatHintsJson(): String =
    """{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset}"""

private fun String.toReadingPosition(): 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()
    return if (index != null && offset != null) ReadingPosition(index, offset) else null
}

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 const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"
