package com.aletheia.app.data

import android.content.Context
import android.util.AtomicFile
import com.aletheia.app.model.CatalogPage
import com.aletheia.app.model.QBooksBook
import java.io.File
import java.security.MessageDigest
import org.json.JSONArray
import org.json.JSONObject

class CatalogCache(context: Context) {
    private val persistentDirectory = File(context.noBackupFilesDir, "CatalogCache-v1").apply { mkdirs() }
    private val pagesDirectory = File(persistentDirectory, "pages").apply { mkdirs() }
    private val booksDirectory = File(persistentDirectory, "books").apply { mkdirs() }
    private val metadataAttemptsDirectory = File(persistentDirectory, "metadata-attempts").apply { mkdirs() }
    private val coversDirectory = File(context.cacheDir, "catalog-v1/covers").apply { mkdirs() }
    private val lock = Any()

    data class CachedPage(
        val page: CatalogPage,
        val savedAtMillis: Long
    ) {
        fun isFresh(maxAgeMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean =
            maxAgeMillis > 0L && nowMillis - savedAtMillis in 0..maxAgeMillis
    }

    fun readPage(key: String): CachedPage? = synchronized(lock) {
        val file = File(pagesDirectory, "${key.sha256()}.json")
        if (!file.isFile) return@synchronized null
        runCatching {
            val json = JSONObject(file.readText(Charsets.UTF_8))
            val booksJson = json.optJSONArray("books") ?: JSONArray()
            val books = buildList {
                for (index in 0 until booksJson.length()) {
                    booksJson.optJSONObject(index)?.toBook()?.let(::add)
                }
            }
            file.setLastModified(System.currentTimeMillis())
            CachedPage(
                page = CatalogPage(
                    books = books,
                    nextPageUrl = json.nullableString("nextPageUrl")
                ),
                savedAtMillis = json.optLong("savedAtMillis", file.lastModified())
            )
        }.getOrElse {
            file.delete()
            null
        }
    }

    fun writePage(key: String, page: CatalogPage) = synchronized(lock) {
        runCatching {
            val json = JSONObject().apply {
                put("savedAtMillis", System.currentTimeMillis())
                put("nextPageUrl", page.nextPageUrl ?: JSONObject.NULL)
                put("books", JSONArray().apply { page.books.forEach { put(it.toJson()) } })
            }
            writeAtomically(
                File(pagesDirectory, "${key.sha256()}.json"),
                json.toString().toByteArray(Charsets.UTF_8)
            )
            trimPageCache()
        }
    }

    fun readBook(key: String): QBooksBook? = synchronized(lock) {
        val file = File(booksDirectory, "${key.sha256()}.json")
        if (!file.isFile) return@synchronized null
        runCatching {
            file.setLastModified(System.currentTimeMillis())
            JSONObject(file.readText(Charsets.UTF_8)).toBook()
        }.getOrElse {
            file.delete()
            null
        }
    }

    fun writeBook(key: String, book: QBooksBook) = synchronized(lock) {
        runCatching {
            writeAtomically(
                File(booksDirectory, "${key.sha256()}.json"),
                book.toJson().toString().toByteArray(Charsets.UTF_8)
            )
            trimFiles(booksDirectory, MAX_BOOK_FILES)
        }
    }

    fun wasMetadataAttemptedRecently(key: String, maxAgeMillis: Long): Boolean = synchronized(lock) {
        val file = File(metadataAttemptsDirectory, key.sha256())
        file.isFile && System.currentTimeMillis() - file.lastModified() in 0..maxAgeMillis
    }

    fun markMetadataAttempt(key: String) = synchronized(lock) {
        runCatching {
            val file = File(metadataAttemptsDirectory, key.sha256())
            if (!file.exists()) writeAtomically(file, byteArrayOf(1))
            file.setLastModified(System.currentTimeMillis())
            trimFiles(metadataAttemptsDirectory, MAX_BOOK_FILES)
        }
    }

    fun readCover(url: String): ByteArray? = synchronized(lock) {
        val file = File(coversDirectory, url.sha256())
        if (!file.isFile || file.length() !in 1..MAX_COVER_FILE_BYTES) return@synchronized null
        runCatching {
            file.setLastModified(System.currentTimeMillis())
            file.readBytes()
        }.getOrNull()
    }

    fun writeCover(url: String, bytes: ByteArray) = synchronized(lock) {
        if (bytes.isEmpty() || bytes.size > MAX_COVER_FILE_BYTES) return@synchronized
        runCatching {
            writeAtomically(File(coversDirectory, url.sha256()), bytes)
            trimCoverCache()
        }
    }

    private fun QBooksBook.toJson(): JSONObject = JSONObject().apply {
        put("id", id)
        put("title", title)
        put("author", author)
        put("format", format)
        put("downloadUrl", downloadUrl)
        put("shareUrl", shareUrl ?: JSONObject.NULL)
        put("coverUrl", coverUrl ?: JSONObject.NULL)
        put("description", description ?: JSONObject.NULL)
        put("language", language ?: JSONObject.NULL)
        put("publisher", publisher ?: JSONObject.NULL)
        put("published", published ?: JSONObject.NULL)
    }

    private fun JSONObject.toBook(): QBooksBook? {
        val id = optString("id").trim()
        val title = optString("title").replace(TRAILING_METADATA_TAG, "").trim()
        val downloadUrl = optString("downloadUrl").trim()
        if (id.isBlank() || title.isBlank()) return null
        return QBooksBook(
            id = id,
            title = title,
            author = optString("author").trim().ifBlank { "Неизвестный автор" },
            format = optString("format").trim().ifBlank { "epub" },
            downloadUrl = downloadUrl,
            shareUrl = nullableString("shareUrl"),
            coverUrl = nullableString("coverUrl"),
            description = nullableString("description"),
            language = nullableString("language"),
            publisher = nullableString("publisher"),
            published = nullableString("published")
        )
    }

    private fun JSONObject.nullableString(name: String): String? =
        if (isNull(name)) null else optString(name).trim().ifBlank { null }

    private fun writeAtomically(target: File, bytes: ByteArray) {
        target.parentFile?.mkdirs()
        val atomicFile = AtomicFile(target)
        var output = atomicFile.startWrite()
        try {
            output.write(bytes)
            atomicFile.finishWrite(output)
        } catch (throwable: Throwable) {
            atomicFile.failWrite(output)
            throw throwable
        }
    }

    private fun trimPageCache() {
        trimFiles(pagesDirectory, MAX_PAGE_FILES)
    }

    private fun trimFiles(directory: File, maxFiles: Int) {
        directory.listFiles()
            ?.filter(File::isFile)
            ?.sortedByDescending(File::lastModified)
            ?.drop(maxFiles)
            ?.forEach(File::delete)
    }

    private fun trimCoverCache() {
        val files = coversDirectory.listFiles()
            ?.filter(File::isFile)
            ?.sortedBy(File::lastModified)
            .orEmpty()
        var totalBytes = files.sumOf(File::length)
        var filesToRemove = (files.size - MAX_COVER_FILES).coerceAtLeast(0)
        for (file in files) {
            if (filesToRemove <= 0 && totalBytes <= MAX_COVER_CACHE_BYTES) break
            totalBytes -= file.length()
            file.delete()
            filesToRemove--
        }
    }

    private fun String.sha256(): String = MessageDigest.getInstance("SHA-256")
        .digest(toByteArray(Charsets.UTF_8))
        .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }

    private companion object {
        const val MAX_PAGE_FILES = 128
        const val MAX_BOOK_FILES = 512
        const val MAX_COVER_FILES = 96
        const val MAX_COVER_FILE_BYTES = 1024L * 1024L
        const val MAX_COVER_CACHE_BYTES = 48L * 1024L * 1024L
        val TRAILING_METADATA_TAG = Regex("\\s*\\[(?:litres(?:\\.ru)?|[a-z]{2,3})]\\s*$", RegexOption.IGNORE_CASE)
    }
}
