package net.sergeych.toread.storage.jdbc

import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Cite
import net.sergeych.toread.fb2.Fb2Epigraph
import net.sergeych.toread.fb2.Fb2EpigraphBlock
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.fb2.Fb2Poem
import net.sergeych.toread.fb2.Fb2PoemBlock
import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookRecord
import java.io.File
import java.security.MessageDigest
import java.util.UUID

data class LibraryScanSummary(
    val scannedFiles: Int,
    val importedFiles: Int,
    val skippedFiles: Int,
    val failedFiles: Int,
    val currentFile: String? = null,
)

data class LibraryImportResult(
    val fileId: String,
    val imported: Boolean,
)

class LibraryScanner(
    private val database: H2LibraryDatabase,
    private val log: (String) -> Unit = {},
) {
    fun scanSubtree(root: File, onProgress: (LibraryScanSummary) -> Unit = {}): LibraryScanSummary {
        require(root.isDirectory) { "Scan root is not a directory: ${root.path}" }
        log("scan start root=${root.absolutePath}")

        var scanned = 0
        var imported = 0
        var skipped = 0
        var failed = 0
        var visited = 0

        root.walkTopDown()
            .forEach { file ->
                if (!file.isFile) return@forEach
                visited += 1
                if (!file.isSupportedBookFile()) {
                    if (visited % 50 == 0) {
                        onProgress(LibraryScanSummary(scanned, imported, skipped, failed, file.name))
                    }
                    return@forEach
                }
                scanned += 1
                log("scan file path=${file.absolutePath} size=${file.length()}")
                onProgress(LibraryScanSummary(scanned, imported, skipped, failed, file.name))
                val result = runCatching { importLinkedFile(file) }
                if (result.isSuccess) {
                    if (result.getOrThrow()) {
                        imported += 1
                        log("scan imported path=${file.absolutePath}")
                    } else {
                        skipped += 1
                        log("scan skipped duplicate path=${file.absolutePath}")
                    }
                } else {
                    failed += 1
                    log("scan failed path=${file.absolutePath} error=${result.exceptionOrNull()?.message}")
                }
                onProgress(LibraryScanSummary(scanned, imported, skipped, failed, file.name))
            }

        val summary = LibraryScanSummary(
            scannedFiles = scanned,
            importedFiles = imported,
            skippedFiles = skipped,
            failedFiles = failed,
        )
        log("scan finish root=${root.absolutePath} scanned=$scanned imported=$imported skipped=$skipped failed=$failed")
        return summary
    }

    fun importExternalFile(
        bytes: ByteArray,
        displayName: String,
        storageUri: String,
        sizeBytes: Long?,
        lastModifiedMillis: Long?,
    ): Boolean =
        importExternalFileAndGetResult(bytes, displayName, storageUri, sizeBytes, lastModifiedMillis).imported

    fun importExternalFileAndGetResult(
        bytes: ByteArray,
        displayName: String,
        storageUri: String,
        sizeBytes: Long?,
        lastModifiedMillis: Long?,
    ): LibraryImportResult {
        val rawSha256 = bytes.sha256Hex()
        if (sizeBytes != null) {
            database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).firstOrNull()?.let {
                return LibraryImportResult(fileId = it.id, imported = false)
            }
        }

        val now = System.currentTimeMillis()
        database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget ->
            val fileId = "file-${UUID.randomUUID()}"
            database.files.upsert(
                BookFileRecord(
                    id = fileId,
                    bookId = duplicateTarget.bookId,
                    bodyId = duplicateTarget.bodyId,
                    bodyClusterId = duplicateTarget.bodyClusterId,
                    duplicateOfFileId = duplicateTarget.id,
                    rawSha256 = rawSha256,
                    format = displayName.bookFormat(),
                    mimeType = displayName.bookMimeType(),
                    sizeBytes = sizeBytes,
                    originalFilename = displayName,
                    storageKind = BookFileStorageKind.EXTERNAL_URI,
                    storageUri = storageUri,
                    lastModifiedMillis = lastModifiedMillis,
                    lastSeenAt = now,
                    importedAt = now,
                    createdAt = now,
                    updatedAt = now,
                )
            )
            return LibraryImportResult(fileId = fileId, imported = true)
        }

        val book = Fb2Format.parse(bytes, displayName)
        val cover = book.coverImage()
        val canonicalText = book.canonicalText()
        val bodyHash = canonicalText.encodeToByteArray().sha256Hex()
        val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion)
        val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}"
        val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) }
        val clusterId = knownCluster?.id ?: "cluster-${UUID.randomUUID()}"
        val duplicateTarget = knownBody?.let {
            database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256)
        }
        val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}"
        val fileId = "file-${UUID.randomUUID()}"

        database.transaction {
            if (duplicateTarget == null || duplicateTarget.bookId == null) {
                books.upsert(
                    BookRecord(
                        id = bookId,
                        title = book.title.ifBlank { displayName.substringBeforeLast('.') },
                        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,
                        createdAt = now,
                        updatedAt = now,
                    )
                )
            }
            if (knownBody == null) {
                bodies.upsert(
                    BookBodyRecord(
                        id = bodyId,
                        exactTextHash = bodyHash,
                        canonicalizationVersion = CanonicalizationVersion,
                        wordCount = canonicalText.wordCount(),
                        language = book.language,
                        createdAt = now,
                    )
                )
                clusters.upsert(
                    BodyClusterRecord(
                        id = clusterId,
                        representativeBodyId = bodyId,
                        createdAt = now,
                    )
                )
            } else if (knownCluster == null) {
                clusters.upsert(
                    BodyClusterRecord(
                        id = clusterId,
                        representativeBodyId = bodyId,
                        createdAt = now,
                    )
                )
            }
            files.upsert(
                BookFileRecord(
                    id = fileId,
                    bookId = bookId,
                    bodyId = bodyId,
                    bodyClusterId = clusterId,
                    duplicateOfFileId = duplicateTarget?.id,
                    rawSha256 = rawSha256,
                    format = displayName.bookFormat(),
                    mimeType = displayName.bookMimeType(),
                    sizeBytes = sizeBytes,
                    originalFilename = displayName,
                    storageKind = BookFileStorageKind.EXTERNAL_URI,
                    storageUri = storageUri,
                    lastModifiedMillis = lastModifiedMillis,
                    lastSeenAt = now,
                    importedAt = now,
                    createdAt = now,
                    updatedAt = now,
                )
            )
        }
        return LibraryImportResult(fileId = fileId, imported = true)
    }

    private fun importLinkedFile(file: File): Boolean {
        val sizeBytes = file.length()
        val lastModifiedMillis = file.lastModified()
        if (database.files.findByOriginalFilenameSizeAndModified(file.name, sizeBytes, lastModifiedMillis).isNotEmpty()) {
            return false
        }
        return importExternalFile(
            bytes = file.readBytes(),
            displayName = file.name,
            storageUri = file.absolutePath,
            sizeBytes = sizeBytes,
            lastModifiedMillis = lastModifiedMillis,
        )
    }

    companion object {
        fun countSupportedBookFiles(root: File): Int {
            require(root.isDirectory) { "Scan root is not a directory: ${root.path}" }
            return root.walkTopDown().count { it.isFile && it.isSupportedBookFile() }
        }
    }
}

private const val CanonicalizationVersion = 1

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

private fun Fb2Book.coverImage(): ScannedCover? {
    val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
    val binary = image?.let(::binaryFor) ?: return null
    return runCatching {
        ScannedCover(
            bytes = binary.base64.decodeBase64Bytes(),
            mimeType = binary.contentType,
        )
    }.getOrNull()
}

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

private fun String.bookFormat(): String =
    if (endsWith(".zip", ignoreCase = true)) "fb2.zip" else "fb2"

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

private fun Fb2Book.canonicalText(): String =
    (bodyEpigraphs.flatMap { it.textBlocks() } + sections.flatMap { it.textBlocks() })
        .joinToString(separator = "\n") { it.normalizeForBodyHash() }
        .trim()

private fun Fb2Section.textBlocks(): List<String> {
    val current = buildList {
        title?.let { add(it) }
        blocks.forEach { block ->
            when (block) {
                Fb2Block.EmptyLine -> Unit
                is Fb2Block.Cite -> addAll(block.cite.textBlocks())
                is Fb2Block.Epigraph -> addAll(block.epigraph.textBlocks())
                is Fb2Block.Image -> Unit
                is Fb2Block.Paragraph -> add(block.content.plainText)
                is Fb2Block.Poem -> addAll(block.poem.textBlocks())
                is Fb2Block.Subtitle -> add(block.content.plainText)
            }
        }
        paragraphs.forEach { add(it) }
    }
    return current + sections.flatMap { it.textBlocks() }
}

private fun Fb2Poem.textBlocks(): List<String> = buildList {
    title.forEach { add(it.plainText) }
    epigraphs.forEach { addAll(it.textBlocks()) }
    blocks.forEach { block ->
        when (block) {
            is Fb2PoemBlock.Stanza -> {
                block.stanza.title.forEach { add(it.plainText) }
                block.stanza.subtitle?.let { add(it.plainText) }
                block.stanza.verses.forEach { add(it.plainText) }
            }
            is Fb2PoemBlock.Subtitle -> add(block.content.plainText)
        }
    }
    textAuthors.forEach { add(it.plainText) }
    date?.let { add(it) }
}

private fun Fb2Epigraph.textBlocks(): List<String> = buildList {
    blocks.forEach { addAll(it.textBlocks()) }
    textAuthors.forEach { add(it.plainText) }
}

private fun Fb2Cite.textBlocks(): List<String> = buildList {
    blocks.forEach { addAll(it.textBlocks()) }
    textAuthors.forEach { add(it.plainText) }
}

private fun Fb2EpigraphBlock.textBlocks(): List<String> =
    when (this) {
        Fb2EpigraphBlock.EmptyLine -> emptyList()
        is Fb2EpigraphBlock.Cite -> cite.textBlocks()
        is Fb2EpigraphBlock.Paragraph -> listOf(content.plainText)
        is Fb2EpigraphBlock.Poem -> poem.textBlocks()
        is Fb2EpigraphBlock.Subtitle -> listOf(content.plainText)
    }

private fun String.normalizeForBodyHash(): String =
    lowercase()
        .replace('\u00ad'.toString(), "")
        .replace(Regex("\\s+"), " ")
        .trim()

private fun String.wordCount(): Int =
    split(Regex("\\s+")).count { it.isNotBlank() }

private fun ByteArray.sha256Hex(): String =
    MessageDigest.getInstance("SHA-256").digest(this).toHex()

private fun ByteArray.toHex(): String = joinToString(separator = "") { byte ->
    (byte.toInt() and 0xff).toString(16).padStart(2, '0')
}

private fun String.decodeBase64Bytes(): ByteArray {
    val clean = filterNot { it.isWhitespace() }
    val output = ArrayList<Byte>(clean.length * 3 / 4)
    var buffer = 0
    var bits = 0
    for (char in clean) {
        if (char == '=') break
        val value = char.base64Value()
        buffer = (buffer shl 6) or value
        bits += 6
        if (bits >= 8) {
            bits -= 8
            output += ((buffer shr bits) and 0xff).toByte()
        }
    }
    return output.toByteArray()
}

private fun Char.base64Value(): Int = when (this) {
    in 'A'..'Z' -> code - 'A'.code
    in 'a'..'z' -> code - 'a'.code + 26
    in '0'..'9' -> code - '0'.code + 52
    '+' -> 62
    '/' -> 63
    else -> error("Invalid base64 character")
}
